Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb06f8eb0c | |||
| e47740e02e | |||
| d9ff0035f5 | |||
| 7a7f3be0d2 | |||
| 91e45fbe95 | |||
| 7d7e9da28c | |||
| 24a9739604 | |||
| 4fb9687782 | |||
| 95ffc21b60 | |||
| f3c5e55b26 | |||
| 40183c6a5c | |||
| 457c59e38a | |||
| aa93a3f2e2 | |||
| 8b9abcb6cc | |||
| 1ecc1908c7 | |||
| 6a2c7b467d | |||
| 0acef57865 | |||
| 43046ee649 | |||
| a15fda0c08 | |||
| e5988764ce | |||
| 9c9d9b5a8d | |||
| 44dc564d85 | |||
| 83e367afab | |||
| 8b7e7c2669 | |||
| 53474021b7 | |||
| da1ed1b5b2 | |||
| e08d661600 | |||
| 1aa1bc7a26 | |||
| 47634e942e | |||
| 15466cbf1a | |||
| 2a749db427 | |||
| ecccce86e4 | |||
| bf3f64bea4 | |||
| 2f2d6b8535 | |||
| d68c884649 | |||
| 8b556de03b | |||
| 7229af53c3 | |||
| 81b3034c2f | |||
| f0419396b5 | |||
| 6b9c2754e8 | |||
| 8edb131f8b | |||
| d6f6520a79 | |||
| cc2bb4d719 | |||
| 3859f1c9ae | |||
| 5f8d774e19 | |||
| 538a3e855c | |||
| 03f2ef1e2b | |||
| 237d0746cf | |||
| 33b6c58087 | |||
| e96b023d04 | |||
| 7ac1d4621b | |||
| a2d7cbe8fe | |||
| c74ed29739 | |||
| 6c8501f122 | |||
| 941e945f74 | |||
| f2844d59e4 | |||
| 047ff187f6 | |||
| 1136c40811 | |||
| 5a78dc864f | |||
| 15c98c3048 | |||
| 0a5b005ce5 | |||
| 47959ee395 | |||
| c54a57838e | |||
| 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 |
|
||||
|
||||
@@ -89,6 +89,7 @@ docs/*
|
||||
!docs/MCP-SERVER.md
|
||||
!docs/CLI-TOOLS.md
|
||||
|
||||
|
||||
# open-sse tests
|
||||
open-sse/test/*
|
||||
|
||||
|
||||
+402
@@ -2,8 +2,410 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
> **Coming next** — see [3.0.0-rc branch](https://github.com/diegosouzapw/OmniRoute/tree/3.0.0-rc).
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.9] — 2026-03-23
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **T29** — Vertex AI SA JSON Executor: implemented using the `jose` library to handle JWT/Service Account auth, along with configurable regions in the UI and automatic partner model URL building.
|
||||
- **T42** — Image generation aspect ratio mapping: created `sizeMapper` logic for generic OpenAI formats (`size`), added native `imagen3` handling, and updated NanoBanana endpoints to utilize mapped aspect ratios automatically.
|
||||
- **T38** — Centralized model specifications: `modelSpecs.ts` created for limits and parameters per model.
|
||||
|
||||
### 🔧 Improvements
|
||||
|
||||
- **T40** — OpenCode CLI tools integration: native `opencode-zen` and `opencode-go` integration completed in earlier PR.
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.8] — 2026-03-23
|
||||
|
||||
### 🔧 Bug Fixes & Improvements (Fallback, Quota & Budget)
|
||||
|
||||
- **T24** — `503` cooldown await fix + `406` mapping: mapped `406 Not Acceptable` to `503 Service Unavailable` with proper cooldown intervals.
|
||||
- **T25** — Provider validation fallback: graceful fallback to standard validation models when a specific `validationModelId` is not present.
|
||||
- **T36** — `403` vs `429` provider handling refinement: extracted into `errorClassifier.ts` to properly segregate hard permissions failures (`403`) from rate limits (`429`).
|
||||
- **T39** — Endpoint Fallback for `fetchAvailableModels`: implemented a tri-tier mechanism (`/models` -> `/v1/models` -> local generic catalog) + `list_models_catalog` MCP tool updates to reflect `source` and `warning`.
|
||||
- **T33** — Thinking level to budget conversion: translates qualitative thinking levels into precise budget allocations.
|
||||
- **T41** — Background task auto redirect: routes heavy background evaluation tasks to flash/efficient models automatically.
|
||||
- **T23** — Intelligent quota reset fallback: accurately extracts `x-ratelimit-reset` / `retry-after` header values or maps static cooldowns.
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.7] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_
|
||||
|
||||
> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 15 sub2api gap improvements (T01–T15 complete).
|
||||
|
||||
### 🆕 New Providers
|
||||
|
||||
| Provider | Alias | Tier | Notes |
|
||||
| ---------------- | -------------- | ---- | -------------------------------------------------------------- |
|
||||
| **OpenCode Zen** | `opencode-zen` | Free | 3 models via `opencode.ai/zen/v1` (PR #530 by @kang-heewon) |
|
||||
| **OpenCode Go** | `opencode-go` | Paid | 4 models via `opencode.ai/zen/go/v1` (PR #530 by @kang-heewon) |
|
||||
|
||||
Both providers use the new `OpencodeExecutor` with multi-format routing (`/chat/completions`, `/messages`, `/responses`, `/models/{model}:generateContent`).
|
||||
|
||||
---
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
#### 🔑 Registered Keys Provisioning API (#464)
|
||||
|
||||
Auto-generate and issue OmniRoute API keys programmatically with per-provider and per-account quota enforcement.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ------------------------------------- | --------- | ------------------------------------------------ |
|
||||
| `/api/v1/registered-keys` | `POST` | Issue a new key — raw key returned **once only** |
|
||||
| `/api/v1/registered-keys` | `GET` | List registered keys (masked) |
|
||||
| `/api/v1/registered-keys/{id}` | `GET` | Get key metadata |
|
||||
| `/api/v1/registered-keys/{id}` | `DELETE` | Revoke a key |
|
||||
| `/api/v1/registered-keys/{id}/revoke` | `POST` | Revoke (for clients without DELETE support) |
|
||||
| `/api/v1/quotas/check` | `GET` | Pre-validate quota before issuing |
|
||||
| `/api/v1/providers/{id}/limits` | `GET/PUT` | Configure per-provider issuance limits |
|
||||
| `/api/v1/accounts/{id}/limits` | `GET/PUT` | Configure per-account issuance limits |
|
||||
| `/api/v1/issues/report` | `POST` | Report quota events to GitHub Issues |
|
||||
|
||||
**DB — Migration 008:** Three new tables: `registered_keys`, `provider_key_limits`, `account_key_limits`.
|
||||
**Security:** Keys stored as SHA-256 hashes. Raw key shown once on creation, never retrievable again.
|
||||
**Quota types:** `maxActiveKeys`, `dailyIssueLimit`, `hourlyIssueLimit` per provider and per account.
|
||||
**Idempotency:** `idempotency_key` field prevents duplicate issuance. Returns `409 IDEMPOTENCY_CONFLICT` if key was already used.
|
||||
**Budget per key:** `dailyBudget` / `hourlyBudget` — limits how many requests a key can route per window.
|
||||
**GitHub reporting:** Optional. Set `GITHUB_ISSUES_REPO` + `GITHUB_ISSUES_TOKEN` to auto-create GitHub issues on quota exceeded or issuance failures.
|
||||
|
||||
#### 🎨 Provider Icons — @lobehub/icons (#529)
|
||||
|
||||
All provider icons in the dashboard now use `@lobehub/icons` React components (130+ providers with SVG).
|
||||
Fallback chain: **Lobehub SVG → existing `/providers/{id}.png` → generic icon**. Uses a proper React `ErrorBoundary` pattern.
|
||||
|
||||
#### 🔄 Model Auto-Sync Scheduler (#488)
|
||||
|
||||
OmniRoute now automatically refreshes model lists for connected providers every **24 hours**.
|
||||
|
||||
- Runs on server startup via the existing `/api/sync/initialize` hook
|
||||
- Configurable via `MODEL_SYNC_INTERVAL_HOURS` environment variable
|
||||
- Covers 16 major providers
|
||||
- Records last sync time in the settings database
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
#### OAuth & Auth
|
||||
|
||||
- **#537 — Gemini CLI OAuth:** Clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker/self-hosted deployments. Previously showed cryptic `client_secret is missing` from Google. Now provides specific `docker-compose.yml` and `~/.omniroute/.env` instructions.
|
||||
|
||||
#### Providers & Routing
|
||||
|
||||
- **#536 — LongCat AI:** Fixed `baseUrl` (`api.longcat.chat/openai`) and `authHeader` (`Authorization: Bearer`).
|
||||
- **#535 — Pinned model override:** `body.model` is now correctly set to `pinnedModel` when context-cache protection is active.
|
||||
- **#532 — OpenCode Go key validation:** Now uses the `zen/v1` test endpoint (`testKeyBaseUrl`) — same key works for both tiers.
|
||||
|
||||
#### CLI & Tools
|
||||
|
||||
- **#527 — Claude Code + Codex loop:** `tool_result` blocks are now converted to text instead of dropped, stopping infinite tool-result loops.
|
||||
- **#524 — OpenCode config save:** Added `saveOpenCodeConfig()` handler (XDG_CONFIG_HOME aware, writes TOML).
|
||||
- **#521 — Login stuck:** Login no longer freezes after skipping password setup — redirects correctly to onboarding.
|
||||
- **#522 — API Manager:** Removed misleading "Copy masked key" button (replaced with a lock icon tooltip).
|
||||
- **#532 — OpenCode Go config:** Guide settings handler now handles `opencode` toolId.
|
||||
|
||||
#### Developer Experience
|
||||
|
||||
- **#489 — Antigravity:** Missing `googleProjectId` returns a structured 422 error with reconnect guidance instead of a cryptic crash.
|
||||
- **#510 — Windows paths:** MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...` automatically.
|
||||
- **#492 — CLI startup:** `omniroute` CLI now detects `mise`/`nvm`-managed Node when `app/server.js` is missing and shows targeted fix instructions.
|
||||
|
||||
---
|
||||
|
||||
### 📖 Documentation Updates
|
||||
|
||||
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
|
||||
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` step documented
|
||||
|
||||
---
|
||||
|
||||
### ✅ Issues Resolved in v3.0.0
|
||||
|
||||
`#464` `#488` `#489` `#492` `#510` `#513` `#520` `#521` `#522` `#524` `#527` `#529` `#532` `#535` `#536` `#537`
|
||||
|
||||
---
|
||||
|
||||
### 🔀 Community PRs Merged
|
||||
|
||||
| PR | Author | Summary |
|
||||
| -------- | ------------ | ---------------------------------------------------------------------- |
|
||||
| **#530** | @kang-heewon | OpenCode Zen + Go providers with `OpencodeExecutor` and improved tests |
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.7] - 2026-03-23
|
||||
|
||||
### 🔧 Improvements (sub2api Gap Analysis — T05, T08, T09, T13, T14)
|
||||
|
||||
- **T05** — Rate-limit DB persistence: `setConnectionRateLimitUntil()`, `isConnectionRateLimited()`, `getRateLimitedConnections()` in `providers.ts`. The existing `rate_limited_until` column is now exposed as a dedicated API — OAuth token refresh must NOT touch this field to prevent rate-limit loops.
|
||||
- **T08** — Per-API-key session limit: `max_sessions INTEGER DEFAULT 0` added to `api_keys` via auto-migration. `sessionManager.ts` gains `registerKeySession()`, `unregisterKeySession()`, `checkSessionLimit()`, and `getActiveSessionCountForKey()`. Callers in `chatCore.js` can enforce the limit and decrement on `req.close`.
|
||||
- **T09** — Codex vs Spark rate-limit scopes: `getCodexModelScope()` and `getCodexRateLimitKey()` in `codex.ts`. Standard models (`gpt-5.x-codex`, `codex-mini`) get scope `"codex"`; spark models (`codex-spark*`) get scope `"spark"`. Rate-limit keys should be `${accountId}:${scope}` so exhausting one pool doesn't block the other.
|
||||
- **T13** — Stale quota display fix: `getEffectiveQuotaUsage(used, resetAt)` returns `0` when the reset window has passed; `formatResetCountdown(resetAt)` returns a human-readable countdown string (e.g. `"2h 35m"`). Both exported from `providers.ts` + `localDb.ts` for dashboard consumption.
|
||||
- **T14** — Proxy fast-fail: new `src/lib/proxyHealth.ts` with `isProxyReachable(proxyUrl, timeoutMs=2000)` (TCP check, ≤2s instead of 30s timeout), `getCachedProxyHealth()`, `invalidateProxyHealth()`, and `getAllProxyHealthStatuses()`. Results cached 30s by default; configurable via `PROXY_FAST_FAIL_TIMEOUT_MS` / `PROXY_HEALTH_CACHE_TTL_MS`.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **832 tests, 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.6] - 2026-03-23
|
||||
|
||||
### 🔧 Bug Fixes & Improvements (sub2api Gap Analysis — T01–T15)
|
||||
|
||||
- **T01** — `requested_model` column in `call_logs` (migration 009): track which model the client originally requested vs the actual routed model. Enables fallback rate analytics.
|
||||
- **T02** — Strip empty text blocks from nested `tool_result.content`: prevents Anthropic 400 errors (`text content blocks must be non-empty`) when Claude Code chains tool results.
|
||||
- **T03** — Parse `x-codex-5h-*` / `x-codex-7d-*` headers: `parseCodexQuotaHeaders()` + `getCodexResetTime()` extract Codex quota windows for precise cooldown scheduling instead of generic 5-min fallback.
|
||||
- **T04** — `X-Session-Id` header for external sticky routing: `extractExternalSessionId()` in `sessionManager.ts` reads `x-session-id` / `x-omniroute-session` headers with `ext:` prefix to avoid collision with internal SHA-256 session IDs. Nginx-compatible (hyphenated header).
|
||||
- **T06** — Account deactivated → permanent block: `isAccountDeactivated()` in `accountFallback.ts` detects 401 deactivation signals and applies a 1-year cooldown to prevent retrying permanently dead accounts.
|
||||
- **T07** — X-Forwarded-For IP validation: new `src/lib/ipUtils.ts` with `extractClientIp()` and `getClientIpFromRequest()` — skips `unknown`/non-IP entries in `X-Forwarded-For` chains (Nginx/proxy-forwarded requests).
|
||||
- **T10** — Credits exhausted → distinct fallback: `isCreditsExhausted()` in `accountFallback.ts` returns 1h cooldown with `creditsExhausted` flag, distinct from generic 429 rate limiting.
|
||||
- **T11** — `max` reasoning effort → 131072 budget tokens: `EFFORT_BUDGETS` and `THINKING_LEVEL_MAP` updated; reverse mapping now returns `"max"` for full-budget responses. Unit test updated.
|
||||
- **T12** — MiniMax M2.7 pricing entries added: `minimax-m2.7`, `MiniMax-M2.7`, `minimax-m2.7-highspeed` added to pricing table (sub2api PR #1120). M2.5/GLM-4.7/GLM-5/Kimi pricing already existed.
|
||||
- **T15** — Array content normalization: `normalizeContentToString()` helper in `openai-to-claude.ts` correctly collapses array-formatted system/tool messages to string before sending to Anthropic.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **832 tests, 0 failures** (unchanged from rc.5)
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.5] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#464** — Registered Keys Provisioning API: auto-issue API keys with per-provider & per-account quota enforcement
|
||||
- `POST /api/v1/registered-keys` — issue keys with idempotency support
|
||||
- `GET /api/v1/registered-keys` — list (masked) registered keys
|
||||
- `GET /api/v1/registered-keys/{id}` — get key metadata
|
||||
- `DELETE /api/v1/registered-keys/{id}` / `POST ../{id}/revoke` — revoke keys
|
||||
- `GET /api/v1/quotas/check` — pre-validate before issuing
|
||||
- `PUT /api/v1/providers/{id}/limits` — set provider issuance limits
|
||||
- `PUT /api/v1/accounts/{id}/limits` — set account issuance limits
|
||||
- `POST /api/v1/issues/report` — optional GitHub issue reporting
|
||||
- DB migration 008: `registered_keys`, `provider_key_limits`, `account_key_limits` tables
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.4] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#530 (PR)** — OpenCode Zen and OpenCode Go providers added (by @kang-heewon)
|
||||
- New `OpencodeExecutor` with multi-format routing (`/chat/completions`, `/messages`, `/responses`)
|
||||
- 7 models across both tiers
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.3] - 2026-03-22
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **#529** — Provider icons now use [@lobehub/icons](https://github.com/lobehub/lobe-icons) with graceful PNG fallback and a `ProviderIcon` component (130+ providers supported)
|
||||
- **#488** — Auto-update model lists every 24h via `modelSyncScheduler` (configurable via `MODEL_SYNC_INTERVAL_HOURS`)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#537** — Gemini CLI OAuth: now shows clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker/self-hosted deployments
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.2] - 2026-03-22
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#536** — LongCat AI key validation: fixed baseUrl (`api.longcat.chat/openai`) and authHeader (`Authorization: Bearer`)
|
||||
- **#535** — Pinned model override: `body.model` is now set to `pinnedModel` when context-cache protection detects a pinned model
|
||||
- **#524** — OpenCode config now saved correctly: added `saveOpenCodeConfig()` handler (XDG_CONFIG_HOME aware, writes TOML)
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.1] - 2026-03-22
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **#521** — Login no longer gets stuck after skipping password setup (redirects to onboarding)
|
||||
- **#522** — API Manager: Removed misleading "Copy masked key" button (replaced with lock icon tooltip)
|
||||
- **#527** — Claude Code + Codex superpowers loop: `tool_result` blocks now converted to text instead of dropped
|
||||
- **#532** — OpenCode GO API key validation now uses the correct `zen/v1` endpoint (`testKeyBaseUrl`)
|
||||
- **#489** — Antigravity: missing `googleProjectId` returns structured 422 error with reconnect guidance
|
||||
- **#510** — Windows: MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...`
|
||||
- **#492** — `omniroute` CLI now detects `mise`/`nvm` when `app/server.js` is missing and shows targeted fix
|
||||
|
||||
### 📖 Documentation
|
||||
|
||||
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
|
||||
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` documented
|
||||
|
||||
### ✅ Closed Issues
|
||||
|
||||
#489, #492, #510, #513, #520, #521, #522, #525, #527, #532
|
||||
|
||||
---
|
||||
|
||||
## [2.9.5] — 2026-03-22
|
||||
|
||||
> Sprint: New OpenCode providers, embedding credentials fix, CLI masked key bug, CACHE_TAG_PATTERN fix.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **CLI tools save masked API key to config files** — `claude-settings`, `cline-settings`, and `openclaw-settings` POST routes now accept a `keyId` param and resolve the real API key from DB before writing to disk. `ClaudeToolCard` updated to send `keyId` instead of the masked display string. Fixes #523, #526.
|
||||
- **Custom embedding providers: `No credentials` error** — `/v1/embeddings` now tracks `credentialsProviderId` separately from the routing prefix, so credentials are fetched from the matching provider node ID rather than the public prefix string. Fixes a regression where `google/gemini-embedding-001` and similar custom-provider models would always fail with a credentials error. Fixes #532-related. (PR #528 by @jacob2826)
|
||||
- **Context cache protection regex misses `\n` prefix** — `CACHE_TAG_PATTERN` in `comboAgentMiddleware.ts` updated to match both literal `\n` (backslash-n) and actual newline U+000A that `combo.ts` streaming injects around the `<omniModel>` tag after fix #515. Fixes #531.
|
||||
|
||||
### ✨ New Providers
|
||||
|
||||
- **OpenCode Zen** — Free tier gateway at `opencode.ai/zen/v1` with 3 models: `minimax-m2.5-free`, `big-pickle`, `gpt-5-nano`
|
||||
- **OpenCode Go** — Subscription service at `opencode.ai/zen/go/v1` with 4 models: `glm-5`, `kimi-k2.5`, `minimax-m2.7` (Claude format), `minimax-m2.5` (Claude format)
|
||||
- Both providers use the new `OpencodeExecutor` which routes dynamically to `/chat/completions`, `/messages`, `/responses`, or `/models/{model}:generateContent` based on the requested model. (PR #530 by @kang-heewon)
|
||||
|
||||
---
|
||||
|
||||
## [2.9.4] — 2026-03-21
|
||||
|
||||
> Sprint: Bug fixes — preserve Codex prompt cache key, fix tagContent JSON escaping, sync expired token status to DB.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(translator)**: Preserve `prompt_cache_key` in Responses API → Chat Completions translation (#517)
|
||||
— The field is a cache-affinity signal used by Codex; stripping it was preventing prompt cache hits.
|
||||
Fixed in `openai-responses.ts` and `responsesApiHelper.ts`.
|
||||
|
||||
- **fix(combo)**: Escape `\n` in `tagContent` so injected JSON string is valid (#515)
|
||||
— Template literal newlines (U+000A) are not allowed unescaped inside JSON string values.
|
||||
Replaced with `\\n` literal sequences in `open-sse/services/combo.ts`.
|
||||
|
||||
- **fix(usage)**: Sync expired token status back to DB on live auth failure (#491)
|
||||
— When the Limits & Quotas live check returns 401/403, the connection `testStatus` is now updated
|
||||
to `"expired"` in the database so the Providers page reflects the same degraded state.
|
||||
Fixed in `src/app/api/usage/[connectionId]/route.ts`.
|
||||
|
||||
---
|
||||
|
||||
## [2.9.3] — 2026-03-21
|
||||
|
||||
> Sprint: Add 5 new free AI providers — LongCat, Pollinations, Cloudflare AI, Scaleway, AI/ML API.
|
||||
|
||||
### ✨ New Providers
|
||||
|
||||
- **feat(providers/longcat)**: Add LongCat AI (`lc/`) — 50M tokens/day free (Flash-Lite) + 500K/day (Chat/Thinking) during public beta. OpenAI-compatible, standard Bearer auth.
|
||||
- **feat(providers/pollinations)**: Add Pollinations AI (`pol/`) — no API key required. Proxies GPT-5, Claude, Gemini, DeepSeek V3, Llama 4 (1 req/15s free). Custom executor handles optional auth.
|
||||
- **feat(providers/cloudflare-ai)**: Add Cloudflare Workers AI (`cf/`) — 10K Neurons/day free (~150 LLM responses or 500s Whisper audio). 50+ models on global edge. Custom executor builds dynamic URL with `accountId` from credentials.
|
||||
- **feat(providers/scaleway)**: Add Scaleway Generative APIs (`scw/`) — 1M free tokens for new accounts. EU/GDPR compliant (Paris). Qwen3 235B, Llama 3.1 70B, Mistral Small 3.2.
|
||||
- **feat(providers/aimlapi)**: Add AI/ML API (`aiml/`) — $0.025/day free credit, 200+ models (GPT-4o, Claude, Gemini, Llama) via single aggregator endpoint.
|
||||
|
||||
### 🔄 Provider Updates
|
||||
|
||||
- **feat(providers/together)**: Add `hasFree: true` + 3 permanently free model IDs: `Llama-3.3-70B-Instruct-Turbo-Free`, `Llama-Vision-Free`, `DeepSeek-R1-Distill-Llama-70B-Free`
|
||||
- **feat(providers/gemini)**: Add `hasFree: true` + `freeNote` (1,500 req/day, no credit card needed, aistudio.google.com)
|
||||
- **chore(providers/gemini)**: Rename display name to `Gemini (Google AI Studio)` for clarity
|
||||
|
||||
### ⚙️ Infrastructure
|
||||
|
||||
- **feat(executors/pollinations)**: New `PollinationsExecutor` — omits `Authorization` header when no API key provided
|
||||
- **feat(executors/cloudflare-ai)**: New `CloudflareAIExecutor` — dynamic URL construction requires `accountId` in provider credentials
|
||||
- **feat(executors)**: Register `pollinations`, `pol`, `cloudflare-ai`, `cf` executor mappings
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
- **docs(readme)**: Expanded free combo stack to 11 providers ($0 forever)
|
||||
- **docs(readme)**: Added 4 new free provider sections (LongCat, Pollinations, Cloudflare AI, Scaleway) with model tables
|
||||
- **docs(readme)**: Updated pricing table with 4 new free tier rows
|
||||
- **docs(i18n/pt-BR)**: Updated pricing table + added LongCat/Pollinations/Cloudflare AI/Scaleway sections in Portuguese
|
||||
- **docs(new-features/ai)**: 10 task spec files + master implementation plan in `docs/new-features/ai/`
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **821 tests, 0 failures** (unchanged)
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -11,7 +11,9 @@ _Your universal API proxy — one endpoint, 44+ providers, zero downtime. Now wi
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE)
|
||||
[](https://omniroute.online)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
@@ -24,6 +26,25 @@ _Your universal API proxy — one endpoint, 44+ providers, zero downtime. Now wi
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New in v3.0.0
|
||||
|
||||
> **Upgrading from v2.9.5?** — See the [full CHANGELOG](CHANGELOG.md#300--2026-03-22-release-candidate--not-yet-merged-to-main) for all changes.
|
||||
|
||||
| Area | Change |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔑 **Registered Keys API** | Auto-provision API keys via `POST /api/v1/registered-keys` with per-provider/account quota enforcement, idempotency, SHA-256 storage, and optional GitHub issue reporting |
|
||||
| 🎨 **Provider Icons** | 130+ provider logos via `@lobehub/icons` (SVG) with PNG → generic fallback chain |
|
||||
| 🔄 **Model Auto-Sync** | 24h scheduler refreshes model lists for 16 providers on startup — configurable via `MODEL_SYNC_INTERVAL_HOURS` |
|
||||
| 🌐 **OpenCode Zen/Go** | Two new providers from @kang-heewon via PR #530: free tier + subscription tier via `OpencodeExecutor` |
|
||||
| 🐛 **Gemini CLI OAuth** | Actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker (was cryptic Google error) |
|
||||
| 🐛 **OpenCode config** | `saveOpenCodeConfig()` now correctly writes TOML to `XDG_CONFIG_HOME` |
|
||||
| 🐛 **Pinned model override** | `body.model` correctly set to `pinnedModel` on context-cache protection |
|
||||
| 🐛 **Codex/Claude loop** | `tool_result` blocks now converted to text to stop infinite loops |
|
||||
| 🐛 **Login redirect** | Login no longer freezes after skipping password setup |
|
||||
| 🐛 **Windows paths** | MSYS2/Git-Bash paths (`/c/...`) normalized to `C:\...` automatically |
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Main Dashboard
|
||||
|
||||
<div align="center">
|
||||
@@ -716,7 +737,7 @@ Outcome: deep fallback depth for deadline-critical workloads
|
||||
|
||||
**Point any IDE/CLI to:** `http://localhost:20128/v1` · API Key: `any-string` · Done.
|
||||
|
||||
> **Optional extra coverage (also free):** Groq API key (30 RPM free), NVIDIA NIM (40 RPM free, 70+ models), Cerebras (1M tok/day).
|
||||
> **Optional extra coverage (also free):** Groq API key (30 RPM free), NVIDIA NIM (40 RPM free, 70+ models), Cerebras (1M tok/day), LongCat API key (50M tokens/day!), Cloudflare Workers AI (10K Neurons/day, 50+ models).
|
||||
|
||||
## ⚡ Quick Start
|
||||
|
||||
@@ -921,18 +942,28 @@ When minimized, OmniRoute lives in your system tray with quick actions:
|
||||
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
|
||||
| | Qwen | **$0** | Unlimited | 4 models unlimited |
|
||||
| | Kiro | **$0** | Unlimited | Claude Sonnet/Haiku (AWS Builder) |
|
||||
| | LongCat Flash-Lite 🆕 | **$0** (50M tok/day 🔥) | 1 RPS | Largest free quota on Earth |
|
||||
| | Pollinations AI 🆕 | **$0** (no key needed) | 1 req/15s | GPT-5, Claude, DeepSeek, Llama 4 |
|
||||
| | Cloudflare Workers AI 🆕 | **$0** (10K Neurons/day) | ~150 resp/day | 50+ models, global edge |
|
||||
| | Scaleway AI 🆕 | **$0** (1M tokens total) | Rate limited | EU/GDPR, Qwen3 235B, Llama 70B |
|
||||
|
||||
> 🆕 **New models added (Mar 2026):** Grok-4 Fast family at $0.20/$0.50/M (benchmarked at 1143ms — 30% faster than Gemini 2.5 Flash), GLM-5 via Z.AI with 128K output, MiniMax M2.5 reasoning, DeepSeek V3.2 updated pricing, Kimi K2.5 via Moonshot direct API.
|
||||
|
||||
**💡 $0 Combo Stack — The Complete Free Setup:**
|
||||
|
||||
```
|
||||
Gemini CLI (180K/mo free)
|
||||
→ iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1)
|
||||
→ Kiro (Claude Sonnet 4.5 + Haiku — unlimited, via AWS Builder ID)
|
||||
→ Qwen (4 models — unlimited)
|
||||
→ Groq (14.4K req/day — ultra-fast)
|
||||
→ NVIDIA NIM (70+ models — 40 RPM forever)
|
||||
# 🆓 Ultimate Free Stack 2026 — 11 Providers, $0 Forever
|
||||
Kiro (kr/) → Claude Sonnet/Haiku UNLIMITED
|
||||
iFlow (if/) → kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 UNLIMITED
|
||||
LongCat Lite (lc/) → LongCat-Flash-Lite — 50M tokens/day 🔥
|
||||
Pollinations (pol/) → GPT-5, Claude, DeepSeek, Llama 4 — no key needed
|
||||
Qwen (qw/) → qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next UNLIMITED
|
||||
Gemini (gemini/) → Gemini 2.5 Flash — 1,500 req/day free API key
|
||||
Cloudflare AI (cf/) → Llama 70B, Gemma 3, Mistral — 10K Neurons/day
|
||||
Scaleway (scw/) → Qwen3 235B, Llama 70B — 1M free tokens (EU)
|
||||
Groq (groq/) → Llama/Gemma ultra-fast — 14.4K req/day
|
||||
NVIDIA NIM (nvidia/) → 70+ open models — 40 RPM forever
|
||||
Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day
|
||||
```
|
||||
|
||||
**Zero cost. Never stops coding.** Configure this as one OmniRoute combo and all fallbacks happen automatically — no manual switching ever.
|
||||
@@ -1003,19 +1034,66 @@ Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b`
|
||||
|
||||
Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whisper-large-v3`
|
||||
|
||||
> **💡 The Ultimate Free Stack:**
|
||||
### 🔴 LONGCAT AI (Free API Key — longcat.chat) 🆕
|
||||
|
||||
| Model | Prefix | Daily Free Quota | Notes |
|
||||
| ----------------------------- | ------ | ----------------- | ----------------------- |
|
||||
| `LongCat-Flash-Lite` | `lc/` | **50M tokens** 💥 | Largest free quota ever |
|
||||
| `LongCat-Flash-Chat` | `lc/` | 500K tokens | Multi-turn chat |
|
||||
| `LongCat-Flash-Thinking` | `lc/` | 500K tokens | Reasoning / CoT |
|
||||
| `LongCat-Flash-Thinking-2601` | `lc/` | 500K tokens | Jan 2026 version |
|
||||
| `LongCat-Flash-Omni-2603` | `lc/` | 500K tokens | Multimodal |
|
||||
|
||||
> 100% free while in public beta. Sign up at [longcat.chat](https://longcat.chat) with email or phone. Resets daily 00:00 UTC.
|
||||
|
||||
### 🟢 POLLINATIONS AI (No API Key Required) 🆕
|
||||
|
||||
| Model | Prefix | Rate Limit | Provider Behind |
|
||||
| ---------- | ------ | ---------- | ------------------ |
|
||||
| `openai` | `pol/` | 1 req/15s | GPT-5 |
|
||||
| `claude` | `pol/` | 1 req/15s | Anthropic Claude |
|
||||
| `gemini` | `pol/` | 1 req/15s | Google Gemini |
|
||||
| `deepseek` | `pol/` | 1 req/15s | DeepSeek V3 |
|
||||
| `llama` | `pol/` | 1 req/15s | Meta Llama 4 Scout |
|
||||
| `mistral` | `pol/` | 1 req/15s | Mistral AI |
|
||||
|
||||
> ✨ **Zero friction:** No signup, no API key. Add the Pollinations provider with an empty key field and it works immediately.
|
||||
|
||||
### 🟠 CLOUDFLARE WORKERS AI (Free API Key — cloudflare.com) 🆕
|
||||
|
||||
| Tier | Daily Neurons | Equivalent Usage | Notes |
|
||||
| ---- | ------------- | --------------------------------------- | ----------------------- |
|
||||
| Free | **10,000** | ~150 LLM resp / 500s audio / 15K embeds | Global edge, 50+ models |
|
||||
|
||||
Popular free models: `@cf/meta/llama-3.3-70b-instruct`, `@cf/google/gemma-3-12b-it`, `@cf/openai/whisper-large-v3-turbo` (free audio!), `@cf/qwen/qwen2.5-coder-15b-instruct`
|
||||
|
||||
> Requires API Token + Account ID from [dash.cloudflare.com](https://dash.cloudflare.com). Store Account ID in provider settings.
|
||||
|
||||
### 🟣 SCALEWAY AI (1M Free Tokens — scaleway.com) 🆕
|
||||
|
||||
| Tier | Free Quota | Location | Notes |
|
||||
| ---- | ------------- | ------------ | ----------------------------------- |
|
||||
| Free | **1M tokens** | 🇫🇷 Paris, EU | No credit card needed within limits |
|
||||
|
||||
Available free: `qwen3-235b-a22b-instruct-2507` (Qwen3 235B!), `llama-3.1-70b-instruct`, `mistral-small-3.2-24b-instruct-2506`, `deepseek-v3-0324`
|
||||
|
||||
> EU/GDPR compliant. Get API key at [console.scaleway.com](https://console.scaleway.com).
|
||||
|
||||
> **💡 The Ultimate Free Stack (11 Providers, $0 Forever):**
|
||||
>
|
||||
> ```
|
||||
> Kiro (Claude, unlimited)
|
||||
> → iFlow (5 models, unlimited)
|
||||
> → Qwen (4 models, unlimited)
|
||||
> → Gemini CLI (180K/mo)
|
||||
> → Cerebras (1M tok/day)
|
||||
> → Groq (14.4K req/day)
|
||||
> → NVIDIA NIM (40 RPM, 70+ models)
|
||||
> Kiro (kr/) → Claude Sonnet/Haiku UNLIMITED
|
||||
> iFlow (if/) → kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 UNLIMITED
|
||||
> LongCat Lite (lc/) → LongCat-Flash-Lite — 50M tokens/day 🔥
|
||||
> Pollinations (pol/) → GPT-5, Claude, DeepSeek, Llama 4 — no key needed
|
||||
> Qwen (qw/) → qwen3-coder models UNLIMITED
|
||||
> Gemini (gemini/) → Gemini 2.5 Flash — 1,500 req/day free
|
||||
> Cloudflare AI (cf/) → 50+ models — 10K Neurons/day
|
||||
> Scaleway (scw/) → Qwen3 235B, Llama 70B — 1M free tokens (EU)
|
||||
> Groq (groq/) → Llama/Gemma — 14.4K req/day ultra-fast
|
||||
> NVIDIA NIM (nvidia/) → 70+ open models — 40 RPM forever
|
||||
> Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day
|
||||
> ```
|
||||
>
|
||||
> Configure this as an OmniRoute combo and you'll never pay for AI again.
|
||||
|
||||
## 🎙️ Free Transcription Combo
|
||||
|
||||
@@ -1105,17 +1183,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,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 校验确保不会接受畸形数据。
|
||||
+21
-2
@@ -189,8 +189,27 @@ const serverJs = join(APP_DIR, "server.js");
|
||||
|
||||
if (!existsSync(serverJs)) {
|
||||
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
|
||||
console.error(" This usually means the package was not built correctly.");
|
||||
console.error(" Try reinstalling: npm install -g omniroute");
|
||||
console.error(" The package may not have been built correctly.");
|
||||
console.error("");
|
||||
// (#492) Detect common non-standard Node managers that cause this issue
|
||||
const nodeExec = process.execPath || "";
|
||||
const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise");
|
||||
const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm");
|
||||
if (isMise) {
|
||||
console.error(
|
||||
" \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`,"
|
||||
);
|
||||
console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)");
|
||||
console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m");
|
||||
} else if (isNvm) {
|
||||
console.error(
|
||||
" \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:"
|
||||
);
|
||||
console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m");
|
||||
} else {
|
||||
console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)");
|
||||
console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -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` проверки за безопасност |
|
||||
|
||||
+225
-349
@@ -2,7 +2,7 @@
|
||||
|
||||
### Nikdy nepřestávejte s kódováním. Chytré směrování k **BEZPLATNÝM a levným modelům AI** s automatickým přepínáním mezi záložními systémy.
|
||||
|
||||
*Váš univerzální API proxy – jeden endpoint, více než 44 poskytovatelů, nulové výpadky. Nyní s orchestrací agentů **MCP a A2A** .*
|
||||
_Váš univerzální API proxy – jeden endpoint, více než 44 poskytovatelů, nulové výpadky. Nyní s orchestrací agentů **MCP a A2A** ._
|
||||
|
||||
**Dokončení chatu • Vkládání • Generování obrázků • Video • Hudba • Audio • Změna pořadí • **Vyhledávání na webu** • MCP server • A2A protokol • 100% TypeScript**
|
||||
|
||||
@@ -30,26 +30,23 @@
|
||||
<summary><b>Kliknutím zobrazíte snímky obrazovky z řídicího panelu</b></summary>
|
||||
</details>
|
||||
|
||||
Strana | Snímek obrazovky
|
||||
--- | ---
|
||||
**Poskytovatelé** | 
|
||||
**Kombinace** | 
|
||||
**Analytika** | 
|
||||
**Zdraví** | 
|
||||
**Překladatel** | 
|
||||
**Nastavení** | 
|
||||
**Nástroje CLI** | 
|
||||
**Protokoly používání** | 
|
||||
**Koncové body** | 
|
||||
|
||||
|
||||
|
||||
| Strana | Snímek obrazovky |
|
||||
| ----------------------- | --------------------------------------------------- |
|
||||
| **Poskytovatelé** |  |
|
||||
| **Kombinace** |  |
|
||||
| **Analytika** |  |
|
||||
| **Zdraví** |  |
|
||||
| **Překladatel** |  |
|
||||
| **Nastavení** |  |
|
||||
| **Nástroje CLI** |  |
|
||||
| **Protokoly používání** |  |
|
||||
| **Koncové body** |  |
|
||||
|
||||
---
|
||||
|
||||
### 🤖 Bezplatný poskytovatel umělé inteligence pro vaše oblíbené programátory
|
||||
|
||||
*Připojte libovolný nástroj IDE nebo CLI s umělou inteligencí přes OmniRoute — bezplatnou API bránu pro neomezené kódování.*
|
||||
_Připojte libovolný nástroj IDE nebo CLI s umělou inteligencí přes OmniRoute — bezplatnou API bránu pro neomezené kódování._
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -68,7 +65,6 @@ Strana | Snímek obrazovky
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<sub>📡 Všichni agenti se připojují přes <code>http://localhost:20128/v1</code> nebo <code>http://cloud.omniroute.online/v1</code> — jedna konfigurace, neomezené modely a kvóty</sub>
|
||||
|
||||
---
|
||||
@@ -161,9 +157,6 @@ Vývojáři platí za Claude Pro, Codex Pro nebo GitHub Copilot 20–200 dolarů
|
||||
- **Vlastní kombinace** — Přizpůsobitelné záložní řetězce se 6 strategiemi vyvažování (fill-first, round robin, P2C, náhodné, nejméně používané, nákladově optimalizované)
|
||||
- **Codex Business Quotas** — Sledování kvót pracovního prostoru firmy/týmu přímo v dashboardu
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔌 2. „Potřebuji použít více poskytovatelů, ale každý má jiné API“</b></summary>
|
||||
</details>
|
||||
@@ -180,9 +173,6 @@ OpenAI používá jeden formát, Claude (Anthropic) jiný a Gemini ještě třet
|
||||
- **Strukturovaný výstup pro Gemini** — `json_schema` → automatická konverze `responseMimeType` / `responseSchema`
|
||||
- **Výchozí hodnota `stream` je `false`** – Odpovídá specifikaci OpenAI, čímž se zabrání neočekávanému SSE v Python/Rust/Go SDK.
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🌐 3. „Můj poskytovatel AI blokuje můj region/zemi“</b></summary>
|
||||
</details>
|
||||
@@ -199,9 +189,6 @@ Poskytovatelé jako OpenAI/Codex blokují přístup z určitých geografických
|
||||
- **TLS Fingerprint Spoofing** — Otisk prstu TLS podobný prohlížeči pomocí `wreq-js` pro obcházení detekce botů
|
||||
- **🔏 Porovnávání otisků prstů v CLI** — Změní pořadí záhlaví a polí v těle serveru tak, aby odpovídala nativním binárním podpisům v CLI, čímž drasticky snižuje riziko nahlašování účtu. IP adresa proxy je zachována — získáte současně stealth **i** maskování IP adresy.
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🆓 4. „Chci používat umělou inteligenci pro kódování, ale nemám peníze“</b></summary>
|
||||
</details>
|
||||
@@ -216,9 +203,6 @@ Ne každý si může dovolit zaplatit 20–200 dolarů měsíčně za předplatn
|
||||
- **NVIDIA NIM Free Access** — ~40 RPM developerský přístup k více než 70 modelům na build.nvidia.com (přechod z kreditů na čisté limity rychlosti)
|
||||
- **Strategie optimalizace nákladů** – Strategie směrování, která automaticky vybere nejlevnějšího dostupného poskytovatele
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔒 5. „Potřebuji chránit svou bránu umělé inteligence před neoprávněným přístupem“</b></summary>
|
||||
</details>
|
||||
@@ -236,9 +220,6 @@ Při zpřístupnění brány umělé inteligence síti (LAN, VPS, Docker) může
|
||||
- **Ochrana proti vkládání výzev** – Sanitizace proti škodlivým vzorcům výzev
|
||||
- **Šifrování AES-256-GCM** – přihlašovací údaje jsou v klidovém stavu šifrovány
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🛑 6. „Můj poskytovatel selhal a já ztratil/a programovací tok“</b></summary>
|
||||
</details>
|
||||
@@ -254,9 +235,6 @@ Poskytovatelé umělé inteligence se mohou stát nestabilními, vracet chyby 5x
|
||||
- **Kombinovaný jistič** – Automaticky deaktivuje selhávajícího poskytovatele v rámci kombinovaného řetězce
|
||||
- **Dashboard stavu** — Monitorování provozuschopnosti, stavy jističů, uzamčení, statistiky mezipaměti, latence p50/p95/p99
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔧 7. „Konfigurace každého nástroje umělé inteligence je zdlouhavá a opakující se“</b></summary>
|
||||
</details>
|
||||
@@ -270,9 +248,6 @@ Vývojáři používají Cursor, Claude Code, Codex CLI, OpenClaw, Gemini CLI, K
|
||||
- **Průvodce zaváděním** – 4krokové nastavení pro začínající uživatele
|
||||
- **Jeden koncový bod, všechny modely** – jednou nakonfigurujte `http://localhost:20128/v1` a získejte přístup k více než 44 poskytovatelům
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔑 8. „Správa OAuth tokenů od více poskytovatelů je peklo“</b></summary>
|
||||
</details>
|
||||
@@ -288,9 +263,6 @@ Claude Code, Codex, Gemini CLI, Copilot – všechny používají OAuth 2.0 s to
|
||||
- **OAuth Behind Nginx** — Používá `window.location.origin` pro kompatibilitu s reverzní proxy
|
||||
- **Průvodce vzdáleným OAuth** – Podrobný návod k přihlašovacím údajům Google Cloud na VPS/Dockeru
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>📊 9. „Nevím, kolik utrácím ani kde“</b></summary>
|
||||
</details>
|
||||
@@ -305,9 +277,6 @@ Vývojáři používají více placených poskytovatelů, ale nemají jednotný
|
||||
- **Statistiky použití pro každý klíč API** — Počet požadavků a časové razítko posledního použití pro každý klíč
|
||||
- **Analytický panel** – Statistické karty, graf využití modelu, tabulka poskytovatelů s mírou úspěšnosti a latencí
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🐛 10. „Nedokážu diagnostikovat chyby a problémy ve volání umělé inteligence.“</b></summary>
|
||||
</details>
|
||||
@@ -324,9 +293,6 @@ Když volání selže, vývojář neví, zda se jednalo o limit rychlosti, vypr
|
||||
- **Souborové protokolování s rotací** – Konzolový interceptor zachycuje vše do protokolu JSON s rotací na základě velikosti
|
||||
- **Zpráva o systémových informacích** — příkaz `npm run system-info` vygeneruje `system-info.txt` s kompletním popisem vašeho prostředí (verze uzlu, verze OmniRoute, operační systém, nástroje CLI, stav Dockeru/PM2). Přiložte jej při hlášení problémů pro okamžité třídění.
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🏗️ 11. „Nasazení a údržba brány je složitá“</b></summary>
|
||||
</details>
|
||||
@@ -343,9 +309,6 @@ Instalace, konfigurace a údržba AI proxy v různých prostředích (lokální,
|
||||
- **Cloud Sync** – Konfigurace synchronizace mezi zařízeními pomocí Cloudflare Workers
|
||||
- **Zálohy databází** — Automatické zálohování, obnovení, export a import všech nastavení
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🌍 12. „Rozhraní je pouze v angličtině a můj tým nemluví anglicky“</b></summary>
|
||||
</details>
|
||||
@@ -359,9 +322,6 @@ Týmy v neanglicky mluvících zemích, zejména v Latinské Americe, Asii a Evr
|
||||
- **Vícejazyčné soubory README** — 30 kompletních překladů dokumentace
|
||||
- **Výběr jazyka** — Ikona glóbu v záhlaví pro přepínání v reálném čase
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔄 13. „Potřebuji víc než jen chat – potřebuji vložené soubory, obrázky, zvuk.“</b></summary>
|
||||
</details>
|
||||
@@ -380,9 +340,6 @@ Umělá inteligence není jen dokončování chatu. Vývojáři potřebují gene
|
||||
- **Změna pořadí** — `/v1/rerank` — Změna pořadí relevance dokumentu
|
||||
- **Responses API** — Plná podpora `/v1/responses` pro Codex
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🧪 14. „Nemám způsob, jak testovat a porovnávat kvalitu napříč modely.“</b></summary>
|
||||
</details>
|
||||
@@ -397,9 +354,6 @@ Vývojáři chtějí vědět, který model je pro jejich případ použití nejl
|
||||
- **Tester chatu** — Kompletní okružní cesta s vizuálním vykreslováním odpovědí
|
||||
- **Živý monitor** — Stream všech požadavků procházejících proxy serverem v reálném čase
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>📈 15. „Potřebuji škálovat bez ztráty výkonu“</b></summary>
|
||||
</details>
|
||||
@@ -415,9 +369,6 @@ S rostoucím objemem požadavků generují stejné otázky bez ukládání do me
|
||||
- **Mezipaměť pro ověření klíčů API** — třívrstvá mezipaměť pro výkon produkčního prostředí
|
||||
- **Dashboard s telemetrií** – latence p50/p95/p99, statistiky mezipaměti, dostupnost
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🤖 16. „Chci mít chování modelů globálně pod kontrolou“</b></summary>
|
||||
</details>
|
||||
@@ -434,9 +385,6 @@ Vývojáři, kteří chtějí všechny odpovědi v určitém jazyce, se specific
|
||||
- **Přepínání poskytovatele** – Povolení/zakázání všech připojení pro poskytovatele jedním kliknutím
|
||||
- **Blokovaní poskytovatelé** – Vyloučení konkrétních poskytovatelů ze seznamu `/v1/models`
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 17. „Potřebuji nástroje MCP jako prvotřídní produktové funkce.“</b></summary>
|
||||
</details>
|
||||
@@ -449,9 +397,6 @@ Mnoho bran umělé inteligence odhaluje MCP pouze jako skrytý implementační d
|
||||
- Vyhrazená stránka pro správu MCP s procesy, nástroji, rozsahy a auditem
|
||||
- Vestavěný rychlý start pro `omniroute --mcp` a onboarding klienta
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🧠 18. „Potřebuji orchestraci A2A se synchronizací a cestami úloh streamu.“</b></summary>
|
||||
</details>
|
||||
@@ -464,9 +409,6 @@ Pracovní postupy agentů vyžadují jak přímé odpovědi, tak dlouhodobé str
|
||||
- Streamování SSE s šířením stavu terminálu
|
||||
- Rozhraní API životního cyklu úloh pro `tasks/get` a `tasks/cancel`
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🛰️ 19. „Potřebuji skutečný stav procesu MCP, ne odhadovaný stav.“</b></summary>
|
||||
</details>
|
||||
@@ -479,9 +421,6 @@ Provozní týmy potřebují vědět, zda je MCP skutečně aktivní, nejen zda j
|
||||
- API stavu MCP kombinující prezenční signál a nedávnou aktivitu
|
||||
- Karty stavu uživatelského rozhraní pro zobrazení aktuálnosti procesů/provozuschopnosti/prezenčního signálu
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>📋 20. „Potřebuji auditovatelné provedení nástroje MCP“</b></summary>
|
||||
</details>
|
||||
@@ -494,9 +433,6 @@ Když nástroje mění konfiguraci nebo spouštějí operační akce, týmy pot
|
||||
- Filtruje podle nástroje, úspěchu/neúspěchu, klíče API a stránkování
|
||||
- Tabulka auditu dashboardu + koncové body statistik pro automatizaci
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔐 21. „Potřebuji omezená oprávnění MCP pro každou integraci.“</b></summary>
|
||||
</details>
|
||||
@@ -509,9 +445,6 @@ Různí klienti by měli mít přístup ke kategoriím nástrojů s nejnižším
|
||||
- Vynucení rozsahu a viditelnost v uživatelském rozhraní správy MCP
|
||||
- Bezpečná výchozí poloha pro provozní nástroje
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>⚙️ 22. „Potřebuji provozní kontroly bez nutnosti přesouvání“</b></summary>
|
||||
</details>
|
||||
@@ -524,9 +457,6 @@ Týmy potřebují rychlé změny v běhovém prostředí během incidentů nebo
|
||||
- Používejte profily odolnosti z předdefinovaných balíčků zásad
|
||||
- Resetujte stav jističe ze stejného ovládacího panelu
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔄 23. „Potřebuji živý přehled o životním cyklu úkolů A2A a jejich zrušení.“</b></summary>
|
||||
</details>
|
||||
@@ -539,9 +469,6 @@ Bez přehledu o životním cyklu je obtížné třídit incidenty úkolů.
|
||||
- Podrobný přehled metadat úloh, událostí a artefaktů
|
||||
- Koncový bod zrušení úlohy a akce uživatelského rozhraní s potvrzením
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🌊 24. „Potřebuji metriky aktivního streamu pro A2A zátěž“</b></summary>
|
||||
</details>
|
||||
@@ -554,9 +481,6 @@ Streamovací pracovní postupy vyžadují provozní přehled o souběžnosti a
|
||||
- Časové razítko posledního úkolu a počty pro jednotlivé stavy
|
||||
- Karty A2A dashboardu pro monitorování provozu v reálném čase
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🪪 25. „Potřebuji standardní vyhledávání agentů pro klienty“</b></summary>
|
||||
</details>
|
||||
@@ -569,9 +493,6 @@ Externí klienti a orchestratoři potřebují pro onboarding strojově čitelná
|
||||
- Schopnosti a dovednosti zobrazené v uživatelském rozhraní pro správu
|
||||
- API pro stav A2A zahrnuje metadata pro zjišťování pro automatizaci
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🧭 26. „Potřebuji v uživatelském rozhraní produktu zjistitelnost protokolu.“</b></summary>
|
||||
</details>
|
||||
@@ -584,9 +505,6 @@ Pokud uživatelé nemohou objevit protokolové povrchy, kvalita přijetí a podp
|
||||
- Přepínání stavu inline služby (Online/Offline) pro MCP a A2A
|
||||
- Odkazy z přehledu na vyhrazené karty pro správu
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🧪 27. „Potřebuji komplexní ověření protokolu se skutečnými klienty.“</b></summary>
|
||||
</details>
|
||||
@@ -599,9 +517,6 @@ Simulované testy nestačí k ověření kompatibility protokolu před vydáním
|
||||
- Klientské testy A2A pro toky zjišťování, odesílání, streamování, načítání a zrušení
|
||||
- Křížová kontrola tvrzení oproti API pro audit MCP a úkoly A2A
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>📡 28. „Potřebuji jednotnou pozorovatelnost napříč všemi rozhraními“</b></summary>
|
||||
</details>
|
||||
@@ -614,9 +529,6 @@ Rozdělení pozorovatelnosti podle protokolu vytváří slepá místa a delší
|
||||
- Stav + audit + telemetrie požadavků napříč vrstvami OpenAI, MCP a A2A
|
||||
- Provozní API pro stav a automatizaci
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>💼 29. „Potřebuji jeden runtime pro proxy + nástroje + orchestraci agentů“</b></summary>
|
||||
</details>
|
||||
@@ -629,9 +541,6 @@ Spouštění mnoha samostatných služeb zvyšuje provozní náklady a počet po
|
||||
- Sdílené ověřování, odolnost, úložiště dat a pozorovatelnost
|
||||
- Konzistentní model politik napříč všemi interakčními plochami
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🚀 30. „Potřebuji agentské pracovní postupy bez slepení kódu.“</b></summary>
|
||||
</details>
|
||||
@@ -644,9 +553,6 @@ Týmy ztrácejí rychlost při spojování více ad-hoc služeb a skriptů.
|
||||
- Vestavěná uživatelská rozhraní pro správu protokolů a cesty pro ověřování kouře
|
||||
- Základy připravené pro produkční prostředí (zabezpečení, protokolování, odolnost, zálohování)
|
||||
|
||||
|
||||
|
||||
|
||||
### Příklady herních plánů (integrované případy užití)
|
||||
|
||||
**Příručka A: Maximalizace placeného předplatného + levné zálohování**
|
||||
@@ -701,13 +607,13 @@ Outcome: deep fallback depth for deadline-critical workloads
|
||||
|
||||
> Nastavte si kódování s umělou inteligencí během několika minut za **0 $/měsíc** . Propojte tyto bezplatné účty a využijte vestavěnou kombinaci **Free Stack** .
|
||||
|
||||
Krok | Akce | Poskytovatelé odemčeni
|
||||
--- | --- | ---
|
||||
1 | Připojení **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 – **neomezeně**
|
||||
2 | Připojení k **iFlow** (Google OAuth) | kimi-k2-myšlení, qwen3-coder-plus, deepseek-r1... — **neomezeně**
|
||||
3 | Připojení **Qwen** (kód zařízení) | qwen3-coder-plus, qwen3-coder-flash... — **neomezeně**
|
||||
4 | Připojení **rozhraní příkazového řádku Gemini** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180 000 GBP/měsíc zdarma**
|
||||
5 | `/dashboard/combos` → Šablona **Free Stack (0 $)** | Automatické zařazení všech bezplatných poskytovatelů do routingu
|
||||
| Krok | Akce | Poskytovatelé odemčeni |
|
||||
| ---- | -------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| 1 | Připojení **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 – **neomezeně** |
|
||||
| 2 | Připojení k **iFlow** (Google OAuth) | kimi-k2-myšlení, qwen3-coder-plus, deepseek-r1... — **neomezeně** |
|
||||
| 3 | Připojení **Qwen** (kód zařízení) | qwen3-coder-plus, qwen3-coder-flash... — **neomezeně** |
|
||||
| 4 | Připojení **rozhraní příkazového řádku Gemini** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180 000 GBP/měsíc zdarma** |
|
||||
| 5 | `/dashboard/combos` → Šablona **Free Stack (0 $)** | Automatické zařazení všech bezplatných poskytovatelů do routingu |
|
||||
|
||||
**V libovolném IDE/CLI naveďte:** `http://localhost:20128/v1` · Klíč API: `any-string` · Hotovo.
|
||||
|
||||
@@ -732,13 +638,13 @@ omniroute
|
||||
|
||||
Dashboard se otevírá na `http://localhost:20128` a základní URL API je `http://localhost:20128/v1` .
|
||||
|
||||
Příkaz | Popis
|
||||
--- | ---
|
||||
`omniroute` | Spuštění serveru ( `PORT=20128` , API a dashboard na stejném portu)
|
||||
`omniroute --port 3000` | Nastavte kanonický/API port na 3000
|
||||
`omniroute --mcp` | Spuštění MCP serveru (transport stdio)
|
||||
`omniroute --no-open` | Neotevírat prohlížeč automaticky
|
||||
`omniroute --help` | Zobrazit nápovědu
|
||||
| Příkaz | Popis |
|
||||
| ----------------------- | ------------------------------------------------------------------- |
|
||||
| `omniroute` | Spuštění serveru ( `PORT=20128` , API a dashboard na stejném portu) |
|
||||
| `omniroute --port 3000` | Nastavte kanonický/API port na 3000 |
|
||||
| `omniroute --mcp` | Spuštění MCP serveru (transport stdio) |
|
||||
| `omniroute --no-open` | Neotevírat prohlížeč automaticky |
|
||||
| `omniroute --help` | Zobrazit nápovědu |
|
||||
|
||||
Volitelný režim s rozděleným portem:
|
||||
|
||||
@@ -847,10 +753,10 @@ docker compose --profile base up -d
|
||||
docker compose --profile cli up -d
|
||||
```
|
||||
|
||||
Obraz | Štítek | Velikost | Popis
|
||||
--- | --- | --- | ---
|
||||
`diegosouzapw/omniroute` | `latest` | ~250 MB | Nejnovější stabilní verze
|
||||
`diegosouzapw/omniroute` | `1.0.3` | ~250 MB | Aktuální verze
|
||||
| Obraz | Štítek | Velikost | Popis |
|
||||
| ------------------------ | -------- | -------- | ------------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250 MB | Nejnovější stabilní verze |
|
||||
| `diegosouzapw/omniroute` | `1.0.3` | ~250 MB | Aktuální verze |
|
||||
|
||||
---
|
||||
|
||||
@@ -893,41 +799,47 @@ Po minimalizaci se OmniRoute nachází v systémové liště a nabízí rychlé
|
||||
|
||||
## 💰 Přehled cen
|
||||
|
||||
Úroveň | Poskytovatel | Náklady | Obnovení kvóty | Nejlepší pro
|
||||
--- | --- | --- | --- | ---
|
||||
**💳 PŘEDPLATNÉ** | Claude Code (profesionál) | 20 dolarů měsíčně | 5 hodin + týdně | Již přihlášen/a k odběru
|
||||
| Kodex (Plus/Pro) | 20–200 USD/měsíc | 5 hodin + týdně | Uživatelé OpenAI
|
||||
| Rozhraní příkazového řádku Gemini | **UVOLNIT** | 180 tisíc měsíčně + 1 tisíc denně | Každý!
|
||||
| GitHub Copilot | 10–19 USD/měsíc | Měsíční | Uživatelé GitHubu
|
||||
**🔑 KLÍČ API** | NVIDIA NIM | **ZDARMA** (vývoj navždy) | ~40 ot./min | 70+ otevřených modelů
|
||||
| Mozky | **ZDARMA** (1 milion tok/den) | 60 000 otáček za minutu / 30 ot./min | Nejrychlejší na světě
|
||||
| Groq | **ZDARMA** (30 ot./min.) | 14,4 tisíc otáček za minutu | Ultrarychlá lama/gema
|
||||
| DeepSeek V3.2 | 0,27/1,10 USD za 1 milion | Žádný | Nejlepší zdůvodnění ceny a kvality
|
||||
| xAI Grok-4 Rychlý | **0,20/0,50 USD za 1 milion** 🆕 | Žádný | Nejrychlejší + volání nástroje, ultranízké
|
||||
| xAI Grok-4 (standardní) | 0,20/1,50 USD za 1 milion 🆕 | Žádný | Vlajková loď Reasoning od xAI
|
||||
| Mistral | Zkušební verze zdarma + placené | Omezená sazba | Evropská umělá inteligence
|
||||
| OpenRouter | Platba za použití | Žádný | Více než 100 modelů agregováno.
|
||||
**💰 LEVNÉ** | GLM-5 (přes Z.AI) 🆕 | 0,5 USD/1 milion | Denně v 10:00 | Výstup 128 tisíc obrazových bodů, nejnovější vlajková loď
|
||||
| GLM-4.7 | 0,6 USD/1 milion | Denně v 10:00 | Záloha rozpočtu
|
||||
| MiniMax M2.5 🆕 | Vstup 0,3 USD/1 milion | 5hodinové válcování | Úvaha + agentní úkoly
|
||||
| MiniMax M2.1 | 0,2 USD/1 milion | 5hodinové válcování | Nejlevnější varianta
|
||||
| Kimi K2.5 (Moonshot API) 🆕 | Platba za použití | Žádný | Přímý přístup k Moonshot API
|
||||
| Kimi K2 | 9 dolarů měsíčně bez závazků | 10 milionů tokenů/měsíc | Předvídatelné náklady
|
||||
**🆓 ZDARMA** | iFlow | **0 dolarů** | Neomezený | 5 modelů neomezeně
|
||||
| Qwen | **0 dolarů** | Neomezený | 4 modely neomezeně
|
||||
| Kiro | **0 dolarů** | Neomezený | Claude Sonnet/Haiku (tvorce AWS)
|
||||
| Úroveň | Poskytovatel | Náklady | Obnovení kvóty | Nejlepší pro |
|
||||
| --------------------------------- | -------------------------------- | ------------------------------------ | ------------------------------------------ | --------------------------------------------------------- |
|
||||
| **💳 PŘEDPLATNÉ** | Claude Code (profesionál) | 20 dolarů měsíčně | 5 hodin + týdně | Již přihlášen/a k odběru |
|
||||
| Kodex (Plus/Pro) | 20–200 USD/měsíc | 5 hodin + týdně | Uživatelé OpenAI |
|
||||
| Rozhraní příkazového řádku Gemini | **UVOLNIT** | 180 tisíc měsíčně + 1 tisíc denně | Každý! |
|
||||
| GitHub Copilot | 10–19 USD/měsíc | Měsíční | Uživatelé GitHubu |
|
||||
| **🔑 KLÍČ API** | NVIDIA NIM | **ZDARMA** (vývoj navždy) | ~40 ot./min | 70+ otevřených modelů |
|
||||
| Mozky | **ZDARMA** (1 milion tok/den) | 60 000 otáček za minutu / 30 ot./min | Nejrychlejší na světě |
|
||||
| Groq | **ZDARMA** (30 ot./min.) | 14,4 tisíc otáček za minutu | Ultrarychlá lama/gema |
|
||||
| DeepSeek V3.2 | 0,27/1,10 USD za 1 milion | Žádný | Nejlepší zdůvodnění ceny a kvality |
|
||||
| xAI Grok-4 Rychlý | **0,20/0,50 USD za 1 milion** 🆕 | Žádný | Nejrychlejší + volání nástroje, ultranízké |
|
||||
| xAI Grok-4 (standardní) | 0,20/1,50 USD za 1 milion 🆕 | Žádný | Vlajková loď Reasoning od xAI |
|
||||
| Mistral | Zkušební verze zdarma + placené | Omezená sazba | Evropská umělá inteligence |
|
||||
| OpenRouter | Platba za použití | Žádný | Více než 100 modelů agregováno. |
|
||||
| **💰 LEVNÉ** | GLM-5 (přes Z.AI) 🆕 | 0,5 USD/1 milion | Denně v 10:00 | Výstup 128 tisíc obrazových bodů, nejnovější vlajková loď |
|
||||
| GLM-4.7 | 0,6 USD/1 milion | Denně v 10:00 | Záloha rozpočtu |
|
||||
| MiniMax M2.5 🆕 | Vstup 0,3 USD/1 milion | 5hodinové válcování | Úvaha + agentní úkoly |
|
||||
| MiniMax M2.1 | 0,2 USD/1 milion | 5hodinové válcování | Nejlevnější varianta |
|
||||
| Kimi K2.5 (Moonshot API) 🆕 | Platba za použití | Žádný | Přímý přístup k Moonshot API |
|
||||
| Kimi K2 | 9 dolarů měsíčně bez závazků | 10 milionů tokenů/měsíc | Předvídatelné náklady |
|
||||
| **🆓 ZDARMA** | iFlow | **0 dolarů** | Neomezený | 5 modelů neomezeně |
|
||||
| Qwen | **0 dolarů** | Neomezený | 4 modely neomezeně |
|
||||
| Kiro | **0 dolarů** | Neomezený | Claude Sonnet/Haiku (tvorce AWS) |
|
||||
|
||||
> 🆕 **Přidány nové modely (březen 2026):** řada Grok-4 Fast za 0,20 USD/0,50 USD/M (benchmarkováno na 1143 ms – o 30 % rychlejší než Gemini 2.5 Flash), GLM-5 přes Z.AI s výstupem 128K, uvažování MiniMax M2.5, aktualizované ceny DeepSeek V3.2, Kimi K2.5 přes Moonshot Direct API.
|
||||
|
||||
**💡 Kombinovaný balík za 0 $ — Kompletní bezplatná instalace:**
|
||||
|
||||
```
|
||||
Gemini CLI (180K/mo free)
|
||||
→ iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1)
|
||||
→ Kiro (Claude Sonnet 4.5 + Haiku — unlimited, via AWS Builder ID)
|
||||
→ Qwen (4 models — unlimited)
|
||||
→ Groq (14.4K req/day — ultra-fast)
|
||||
→ NVIDIA NIM (70+ models — 40 RPM forever)
|
||||
# 🆓 Ultimate Free Stack 2026 — 11 Providers, $0 Forever
|
||||
Kiro (kr/) → Claude Sonnet/Haiku UNLIMITED
|
||||
iFlow (if/) → kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 UNLIMITED
|
||||
LongCat Lite (lc/) → LongCat-Flash-Lite — 50M tokens/day 🔥
|
||||
Pollinations (pol/) → GPT-5, Claude, DeepSeek, Llama 4 — no key needed
|
||||
Qwen (qw/) → qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next UNLIMITED
|
||||
Gemini (gemini/) → Gemini 2.5 Flash — 1,500 req/day free API key
|
||||
Cloudflare AI (cf/) → Llama 70B, Gemma 3, Mistral — 10K Neurons/day
|
||||
Scaleway (scw/) → Qwen3 235B, Llama 70B — 1M free tokens (EU)
|
||||
Groq (groq/) → Llama/Gemma ultra-fast — 14.4K req/day
|
||||
NVIDIA NIM (nvidia/) → 70+ open models — 40 RPM forever
|
||||
Cerebras (cerebras/) → Llama/Qwen world-fastest — 1M tok/day
|
||||
```
|
||||
|
||||
**Nulové náklady. Nikdy nepřestávejte s kódováním.** Nakonfigurujte si to jako jednu kombinaci OmniRoute a všechny záložní režimy se provede automaticky – žádné ruční přepínání.
|
||||
@@ -942,59 +854,59 @@ Gemini CLI (180K/mo free)
|
||||
|
||||
### 🔵 CLAUDE MODELS (přes Kiro — AWS Builder ID)
|
||||
|
||||
Model | Předpona | Omezit | Limit rychlosti
|
||||
--- | --- | --- | ---
|
||||
`claude-sonnet-4.5` | `kr/` | **Neomezený** | Žádný hlášený denní limit
|
||||
`claude-haiku-4.5` | `kr/` | **Neomezený** | Žádný hlášený denní limit
|
||||
`claude-opus-4.6` | `kr/` | **Neomezený** | Nejnovější opus od Kira
|
||||
| Model | Předpona | Omezit | Limit rychlosti |
|
||||
| ------------------- | -------- | ------------- | ------------------------- |
|
||||
| `claude-sonnet-4.5` | `kr/` | **Neomezený** | Žádný hlášený denní limit |
|
||||
| `claude-haiku-4.5` | `kr/` | **Neomezený** | Žádný hlášený denní limit |
|
||||
| `claude-opus-4.6` | `kr/` | **Neomezený** | Nejnovější opus od Kira |
|
||||
|
||||
### 🟢 MODELY IFLOW (Bezplatné OAuth — bez nutnosti platit kreditní kartou)
|
||||
|
||||
Model | Předpona | Omezit | Limit rychlosti
|
||||
--- | --- | --- | ---
|
||||
`kimi-k2-thinking` | `if/` | **Neomezený** | Žádný hlášený strop
|
||||
`qwen3-coder-plus` | `if/` | **Neomezený** | Žádný hlášený strop
|
||||
`deepseek-r1` | `if/` | **Neomezený** | Žádný hlášený strop
|
||||
`minimax-m2.1` | `if/` | **Neomezený** | Žádný hlášený strop
|
||||
`kimi-k2` | `if/` | **Neomezený** | Žádný hlášený strop
|
||||
| Model | Předpona | Omezit | Limit rychlosti |
|
||||
| ------------------ | -------- | ------------- | ------------------- |
|
||||
| `kimi-k2-thinking` | `if/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `qwen3-coder-plus` | `if/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `deepseek-r1` | `if/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `minimax-m2.1` | `if/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `kimi-k2` | `if/` | **Neomezený** | Žádný hlášený strop |
|
||||
|
||||
### 🟡 MODELY QWEN (Ověření kódu zařízení)
|
||||
|
||||
Model | Předpona | Omezit | Limit rychlosti
|
||||
--- | --- | --- | ---
|
||||
`qwen3-coder-plus` | `qw/` | **Neomezený** | Žádný hlášený strop
|
||||
`qwen3-coder-flash` | `qw/` | **Neomezený** | Žádný hlášený strop
|
||||
`qwen3-coder-next` | `qw/` | **Neomezený** | Žádný hlášený strop
|
||||
`vision-model` | `qw/` | **Neomezený** | Multimodální (obrázky)
|
||||
| Model | Předpona | Omezit | Limit rychlosti |
|
||||
| ------------------- | -------- | ------------- | ---------------------- |
|
||||
| `qwen3-coder-plus` | `qw/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `qwen3-coder-flash` | `qw/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `qwen3-coder-next` | `qw/` | **Neomezený** | Žádný hlášený strop |
|
||||
| `vision-model` | `qw/` | **Neomezený** | Multimodální (obrázky) |
|
||||
|
||||
### 🟣 Rozhraní GEMINI CLI (Google OAuth)
|
||||
|
||||
Model | Předpona | Omezit | Limit rychlosti
|
||||
--- | --- | --- | ---
|
||||
`gemini-3-flash-preview` | `gc/` | **180 tisíc tok/měsíc** + 1 tisíc/den | Měsíční reset
|
||||
`gemini-2.5-pro` | `gc/` | 180 tisíc měsíčně (sdílený bazén) | Vysoká kvalita
|
||||
| Model | Předpona | Omezit | Limit rychlosti |
|
||||
| ------------------------ | -------- | ------------------------------------- | --------------- |
|
||||
| `gemini-3-flash-preview` | `gc/` | **180 tisíc tok/měsíc** + 1 tisíc/den | Měsíční reset |
|
||||
| `gemini-2.5-pro` | `gc/` | 180 tisíc měsíčně (sdílený bazén) | Vysoká kvalita |
|
||||
|
||||
### ⚫ NVIDIA NIM (Bezplatný klíč API — build.nvidia.com)
|
||||
|
||||
Úroveň | Denní limit | Limit rychlosti | Poznámky
|
||||
--- | --- | --- | ---
|
||||
Zdarma (vývojář) | Žádný limit tokenů | **~40 ot./min** | Více než 70 modelů; přechod na čisté limity sazeb v polovině roku 2025
|
||||
| Úroveň | Denní limit | Limit rychlosti | Poznámky |
|
||||
| ---------------- | ------------------ | --------------- | ---------------------------------------------------------------------- |
|
||||
| Zdarma (vývojář) | Žádný limit tokenů | **~40 ot./min** | Více než 70 modelů; přechod na čisté limity sazeb v polovině roku 2025 |
|
||||
|
||||
Oblíbené bezplatné modely: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct` , `deepseek/deepseek-r1`
|
||||
|
||||
### ⚪ CEREBRAS (Bezplatný klíč API — inference.cerebras.ai)
|
||||
|
||||
Úroveň | Denní limit | Limit rychlosti | Poznámky
|
||||
--- | --- | --- | ---
|
||||
Uvolnit | **1 milion tokenů/den** | 60 000 otáček za minutu / 30 ot./min | Nejrychlejší inference LLM na světě; denně se resetuje
|
||||
| Úroveň | Denní limit | Limit rychlosti | Poznámky |
|
||||
| ------- | ----------------------- | ------------------------------------ | ------------------------------------------------------ |
|
||||
| Uvolnit | **1 milion tokenů/den** | 60 000 otáček za minutu / 30 ot./min | Nejrychlejší inference LLM na světě; denně se resetuje |
|
||||
|
||||
Dostupné zdarma: `llama-3.3-70b` , `llama-3.1-8b` , `deepseek-r1-distill-llama-70b`
|
||||
|
||||
### 🔴 GROQ (Bezplatný API klíč — console.groq.com)
|
||||
|
||||
Úroveň | Denní limit | Limit rychlosti | Poznámky
|
||||
--- | --- | --- | ---
|
||||
Uvolnit | **14,4 tisíc otáček za minutu** | 30 ot./min na model | Žádná kreditní karta; limit 429, neúčtováno
|
||||
| Úroveň | Denní limit | Limit rychlosti | Poznámky |
|
||||
| ------- | ------------------------------- | ------------------- | ------------------------------------------- |
|
||||
| Uvolnit | **14,4 tisíc otáček za minutu** | 30 ot./min na model | Žádná kreditní karta; limit 429, neúčtováno |
|
||||
|
||||
K dispozici zdarma: `llama-3.3-70b-versatile` , `gemma2-9b-it` , `mixtral-8x7b` , `whisper-large-v3`
|
||||
|
||||
@@ -1016,11 +928,11 @@ K dispozici zdarma: `llama-3.3-70b-versatile` , `gemma2-9b-it` , `mixtral-8x7b`
|
||||
|
||||
> Přepisujte libovolné audio/video za **0 $** – Deepgram leady za 200 $ zdarma, AssemblyAI za 50 $ jako záložní nástroj, Groq Whisper jako neomezená nouzová záloha.
|
||||
|
||||
Poskytovatel | Bezplatné kredity | Nejlepší model | Limit rychlosti
|
||||
--- | --- | --- | ---
|
||||
🟢 **Deepgram** | **200 dolarů zdarma** (registrace) | `nova-3` — nejvyšší přesnost, více než 30 jazyků | Žádný limit RPM pro kredity zdarma
|
||||
🔵 **AssemblyAI** | **50 dolarů zdarma** (registrace) | `universal-3-pro` — kapitoly, sentiment, osobní údaje | Žádný limit RPM pro kredity zdarma
|
||||
🔴 **Groq** | **Navždy zdarma** | `whisper-large-v3` — OpenAI Šepot | 30 ot./min (omezená rychlost)
|
||||
| Poskytovatel | Bezplatné kredity | Nejlepší model | Limit rychlosti |
|
||||
| ----------------- | ---------------------------------- | ----------------------------------------------------- | ---------------------------------- |
|
||||
| 🟢 **Deepgram** | **200 dolarů zdarma** (registrace) | `nova-3` — nejvyšší přesnost, více než 30 jazyků | Žádný limit RPM pro kredity zdarma |
|
||||
| 🔵 **AssemblyAI** | **50 dolarů zdarma** (registrace) | `universal-3-pro` — kapitoly, sentiment, osobní údaje | Žádný limit RPM pro kredity zdarma |
|
||||
| 🔴 **Groq** | **Navždy zdarma** | `whisper-large-v3` — OpenAI Šepot | 30 ot./min (omezená rychlost) |
|
||||
|
||||
**Navrhovaná kombinace v `/dashboard/combos` :**
|
||||
|
||||
@@ -1041,118 +953,118 @@ OmniRoute v2.0 je navržen jako operační platforma, nikoli pouze jako proxy pr
|
||||
|
||||
### 🆕 Nové — Vylepšení inspirovaná ClawRouterem (březen 2026)
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
⚡ **Grok-4 Rychlá rodina** | Modely xAI za 0,20 USD/0,50 USD/M – v benchmarku 1143 ms (o 30 % rychlejší než Gemini 2.5 Flash)
|
||||
🧠 **GLM-5 přes Z.AI** | 128 tisíc výstupních dat, 0,5 USD/1 milion USD – nejnovější vlajková loď rodiny GLM
|
||||
🔮 **MiniMax M2.5** | Úvaha + agentní úkoly za 0,30 USD/1 milion – významný upgrade oproti M2.1
|
||||
🎯 **Příznak volání nástroje pro každý model** | `toolCalling: true/false` v registru — AutoCombo přeskakuje modely, které nepodporují nástroje.
|
||||
🌍 **Detekce vícejazyčného záměru** | Klíčová slova PT/ZH/ES/AR v bodování AutoCombo – lepší výběr modelu pro neanglický obsah
|
||||
📊 **Záložní metody řízené benchmarkem** | Skutečná latence p95 z živých požadavků poskytuje kombinované skóre – AutoCombo se učí ze skutečných dat
|
||||
🔁 **Požádat o deduplikaci** | Okno pro deduplikaci na základě hashování obsahu – bezpečné pro více agentů, zabraňuje duplicitním platbám
|
||||
🔌 **Strategie pro zásuvné routery** | Rozšiřitelné rozhraní `RouterStrategy` – přidejte si vlastní logiku směrování jako pluginy
|
||||
| Funkce | Co to dělá |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| ⚡ **Grok-4 Rychlá rodina** | Modely xAI za 0,20 USD/0,50 USD/M – v benchmarku 1143 ms (o 30 % rychlejší než Gemini 2.5 Flash) |
|
||||
| 🧠 **GLM-5 přes Z.AI** | 128 tisíc výstupních dat, 0,5 USD/1 milion USD – nejnovější vlajková loď rodiny GLM |
|
||||
| 🔮 **MiniMax M2.5** | Úvaha + agentní úkoly za 0,30 USD/1 milion – významný upgrade oproti M2.1 |
|
||||
| 🎯 **Příznak volání nástroje pro každý model** | `toolCalling: true/false` v registru — AutoCombo přeskakuje modely, které nepodporují nástroje. |
|
||||
| 🌍 **Detekce vícejazyčného záměru** | Klíčová slova PT/ZH/ES/AR v bodování AutoCombo – lepší výběr modelu pro neanglický obsah |
|
||||
| 📊 **Záložní metody řízené benchmarkem** | Skutečná latence p95 z živých požadavků poskytuje kombinované skóre – AutoCombo se učí ze skutečných dat |
|
||||
| 🔁 **Požádat o deduplikaci** | Okno pro deduplikaci na základě hashování obsahu – bezpečné pro více agentů, zabraňuje duplicitním platbám |
|
||||
| 🔌 **Strategie pro zásuvné routery** | Rozšiřitelné rozhraní `RouterStrategy` – přidejte si vlastní logiku směrování jako pluginy |
|
||||
|
||||
### 🚀 Předchozí verze v2.0.9+ — Hřiště, otisky prstů v CLI a ACP
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
🎮 **Modelové hřiště** | Stránka řídicího panelu pro přímé testování libovolného modelu – selektory poskytovatele/modelu/koncového bodu, editor Monaco, streamování, přerušení, načasování
|
||||
🔏 **Porovnávání otisků prstů v CLI** | Řazení hlaviček/těl serveru podle poskytovatele tak, aby odpovídalo nativním podpisům CLI – přepínání pro jednotlivé poskytovatele v Nastavení > Zabezpečení. **Vaše IP adresa proxy serveru je zachována.**
|
||||
🤝 **Podpora ACP (Agent Client Protocol)** | Vyhledávání agentů CLI (Codex, Claude, Goose, Gemini CLI, OpenClaw + 9 dalších), generátor procesů, koncový bod `/api/acp/agents`
|
||||
🤖 **Řídicí panel agentů ACP** | Ladění › Stránka Agenti — mřížka 14 agentů se stavem instalace, verzí a formulářem pro vlastní agenta pro libovolný nástroj CLI. Uživatelé **OpenCode** získají tlačítko „Stáhnout opencode.json“, které automaticky vygeneruje konfiguraci připravenou k použití se všemi dostupnými modely.
|
||||
🔧 **Směrování `apiFormat` pro vlastní model** | Vlastní modely s `apiFormat: "responses"` nyní správně směrují do překladače Responses API.
|
||||
🏢 **Izolace pracovního prostoru Codexu** | Více pracovních prostorů Codexu na jeden e-mail – OAuth správně odděluje připojení podle ID pracovního prostoru
|
||||
🔄 **Automatická aktualizace elektronů** | Desktopová aplikace kontroluje aktualizace + automaticky se instaluje po restartu
|
||||
| Funkce | Co to dělá |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🎮 **Modelové hřiště** | Stránka řídicího panelu pro přímé testování libovolného modelu – selektory poskytovatele/modelu/koncového bodu, editor Monaco, streamování, přerušení, načasování |
|
||||
| 🔏 **Porovnávání otisků prstů v CLI** | Řazení hlaviček/těl serveru podle poskytovatele tak, aby odpovídalo nativním podpisům CLI – přepínání pro jednotlivé poskytovatele v Nastavení > Zabezpečení. **Vaše IP adresa proxy serveru je zachována.** |
|
||||
| 🤝 **Podpora ACP (Agent Client Protocol)** | Vyhledávání agentů CLI (Codex, Claude, Goose, Gemini CLI, OpenClaw + 9 dalších), generátor procesů, koncový bod `/api/acp/agents` |
|
||||
| 🤖 **Řídicí panel agentů ACP** | Ladění › Stránka Agenti — mřížka 14 agentů se stavem instalace, verzí a formulářem pro vlastní agenta pro libovolný nástroj CLI. Uživatelé **OpenCode** získají tlačítko „Stáhnout opencode.json“, které automaticky vygeneruje konfiguraci připravenou k použití se všemi dostupnými modely. |
|
||||
| 🔧 **Směrování `apiFormat` pro vlastní model** | Vlastní modely s `apiFormat: "responses"` nyní správně směrují do překladače Responses API. |
|
||||
| 🏢 **Izolace pracovního prostoru Codexu** | Více pracovních prostorů Codexu na jeden e-mail – OAuth správně odděluje připojení podle ID pracovního prostoru |
|
||||
| 🔄 **Automatická aktualizace elektronů** | Desktopová aplikace kontroluje aktualizace + automaticky se instaluje po restartu |
|
||||
|
||||
### 🤖 Operace s agenty a protokoly (v2.0)
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
🔧 **MCP Server (16 nástrojů)** | Nástroje IDE/agent prostřednictvím 3 transportů: stdio, SSE ( `/api/mcp/sse` ), Streamovatelný HTTP ( `/api/mcp/stream` )
|
||||
🤝 **A2A server (JSON-RPC + SSE)** | Spouštění úloh mezi agenty se synchronizací a streamováním
|
||||
🧭 **Konsolidovaná stránka koncových bodů** | Stránka pro správu s kartami Endpoint Proxy, MCP, A2A a API Endpoints
|
||||
🎚️ **Přepínače pro povolení/zakázání služby** | Přepínače ZAP/VYP pro MCP a A2A s trvalým nastavením (výchozí: VYP)
|
||||
🛰️ **Srdeční tep za běhu MCP** | Skutečný stav procesu (pid, doba provozuschopnosti, stáří heartbeatu, transport, režim rozsahu)
|
||||
📋 **Auditní záznam MCP** | Filtrovatelné protokoly auditu s hodnocením úspěchu/neúspěchu a klíčovým přiřazením
|
||||
🔐 **Vynucování rozsahu MCP** | 9 podrobných oprávnění pro řízený přístup k nástrojům
|
||||
📡 **Správa životního cyklu úkolů A2A** | Seznam/filtrování úloh, kontrola událostí/artefaktů, zrušení spuštěných úloh
|
||||
📋 **Objevení karty agenta** | `/.well-known/agent.json` pro automatické vyhledávání klientů
|
||||
🧪 **Testovací postroj Protocol E2E** | Skutečné MCP SDK + toky klientů A2A v `test:protocols:e2e`
|
||||
⚙️ **Provozní kontroly** | Kombinace přepínačů, použití profilů odolnosti, resetování jističů z jednoho ovládacího panelu
|
||||
| Funkce | Co to dělá |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔧 **MCP Server (16 nástrojů)** | Nástroje IDE/agent prostřednictvím 3 transportů: stdio, SSE ( `/api/mcp/sse` ), Streamovatelný HTTP ( `/api/mcp/stream` ) |
|
||||
| 🤝 **A2A server (JSON-RPC + SSE)** | Spouštění úloh mezi agenty se synchronizací a streamováním |
|
||||
| 🧭 **Konsolidovaná stránka koncových bodů** | Stránka pro správu s kartami Endpoint Proxy, MCP, A2A a API Endpoints |
|
||||
| 🎚️ **Přepínače pro povolení/zakázání služby** | Přepínače ZAP/VYP pro MCP a A2A s trvalým nastavením (výchozí: VYP) |
|
||||
| 🛰️ **Srdeční tep za běhu MCP** | Skutečný stav procesu (pid, doba provozuschopnosti, stáří heartbeatu, transport, režim rozsahu) |
|
||||
| 📋 **Auditní záznam MCP** | Filtrovatelné protokoly auditu s hodnocením úspěchu/neúspěchu a klíčovým přiřazením |
|
||||
| 🔐 **Vynucování rozsahu MCP** | 9 podrobných oprávnění pro řízený přístup k nástrojům |
|
||||
| 📡 **Správa životního cyklu úkolů A2A** | Seznam/filtrování úloh, kontrola událostí/artefaktů, zrušení spuštěných úloh |
|
||||
| 📋 **Objevení karty agenta** | `/.well-known/agent.json` pro automatické vyhledávání klientů |
|
||||
| 🧪 **Testovací postroj Protocol E2E** | Skutečné MCP SDK + toky klientů A2A v `test:protocols:e2e` |
|
||||
| ⚙️ **Provozní kontroly** | Kombinace přepínačů, použití profilů odolnosti, resetování jističů z jednoho ovládacího panelu |
|
||||
|
||||
### 🧠 Směrování a inteligence
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
🎯 **Inteligentní čtyřúrovňový záložní systém** | Automatická trasa: Předplatné → API klíč → Levné → Zdarma
|
||||
📊 **Sledování kvót v reálném čase** | Počet tokenů v reálném čase + odpočet resetování pro každého poskytovatele
|
||||
🔄 **Překlad formátu** | OpenAI ↔ Claude ↔ Gemini ↔ Odpovědi s konverzemi bezpečnými pro schéma
|
||||
👥 **Podpora více účtů** | Více účtů na poskytovatele s inteligentním výběrem
|
||||
🔄 **Automatická aktualizace tokenů** | Tokeny OAuth se automaticky obnovují při opakovaném pokusu.
|
||||
🎨 **Vlastní kombinace** | 6 vyvažovacích strategií + řízení záložního řetězce
|
||||
🌐 **Směrovač se zástupnými znaky** | dynamické směrování `provider/*`
|
||||
🧠 **Přemýšlení o rozpočtových kontrolách** | Limity pro průchozí, automatické, vlastní a adaptivní uvažování
|
||||
🔀 **Aliasy modelů** | Vestavěné + vlastní aliasování modelů a bezpečnost migrace
|
||||
⚡ **Degradace pozadí** | Směrujte úlohy na pozadí s nízkou prioritou na levnější modely
|
||||
🧪 **Chytré směrování s ohledem na úkoly** | Automatický výběr modelu podle typu obsahu (kódování/vize/analýza/sumarizace)
|
||||
💬 **Vstřikování do systému** | Globální kontroly chování uplatňované konzistentně
|
||||
📄 **Kompatibilita API pro odpovědi** | Plná podpora `/v1/responses` pro Codex a pokročilé agentické pracovní postupy
|
||||
| Funkce | Co to dělá |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| 🎯 **Inteligentní čtyřúrovňový záložní systém** | Automatická trasa: Předplatné → API klíč → Levné → Zdarma |
|
||||
| 📊 **Sledování kvót v reálném čase** | Počet tokenů v reálném čase + odpočet resetování pro každého poskytovatele |
|
||||
| 🔄 **Překlad formátu** | OpenAI ↔ Claude ↔ Gemini ↔ Odpovědi s konverzemi bezpečnými pro schéma |
|
||||
| 👥 **Podpora více účtů** | Více účtů na poskytovatele s inteligentním výběrem |
|
||||
| 🔄 **Automatická aktualizace tokenů** | Tokeny OAuth se automaticky obnovují při opakovaném pokusu. |
|
||||
| 🎨 **Vlastní kombinace** | 6 vyvažovacích strategií + řízení záložního řetězce |
|
||||
| 🌐 **Směrovač se zástupnými znaky** | dynamické směrování `provider/*` |
|
||||
| 🧠 **Přemýšlení o rozpočtových kontrolách** | Limity pro průchozí, automatické, vlastní a adaptivní uvažování |
|
||||
| 🔀 **Aliasy modelů** | Vestavěné + vlastní aliasování modelů a bezpečnost migrace |
|
||||
| ⚡ **Degradace pozadí** | Směrujte úlohy na pozadí s nízkou prioritou na levnější modely |
|
||||
| 🧪 **Chytré směrování s ohledem na úkoly** | Automatický výběr modelu podle typu obsahu (kódování/vize/analýza/sumarizace) |
|
||||
| 💬 **Vstřikování do systému** | Globální kontroly chování uplatňované konzistentně |
|
||||
| 📄 **Kompatibilita API pro odpovědi** | Plná podpora `/v1/responses` pro Codex a pokročilé agentické pracovní postupy |
|
||||
|
||||
### 🎵 Multimodální API
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
🖼️ **Generování obrázků** | `/v1/images/generations` s cloudovým a lokálním backendem
|
||||
📐 **Vložení** | `/v1/embeddings` pro vyhledávání a RAG pipelines
|
||||
🎤 **Přepis zvuku** | `/v1/audio/transcriptions` (Whisper a další poskytovatelé)
|
||||
🔊 **Převod textu na řeč** | `/v1/audio/speech` (více enginů/poskytovatelů)
|
||||
🎬 **Generování videa** | `/v1/videos/generations` (pracovní postupy ComfyUI + SD WebUI)
|
||||
🎵 **Hudební generace** | `/v1/music/generations` (pracovní postupy ComfyUI)
|
||||
🛡️ **Moderování** | Bezpečnostní kontroly `/v1/moderations`
|
||||
🔀 **Změna pořadí** | `/v1/rerank` pro hodnocení relevance
|
||||
🔍 **Vyhledávání na webu** 🆕 | `/v1/search` — 5 poskytovatelů (Serper, Brave, Perplexity, Exa, Tavily), více než 6 500 zdarma/měsíc, automatické přepnutí na záložní systém, mezipaměť
|
||||
| Funkce | Co to dělá |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🖼️ **Generování obrázků** | `/v1/images/generations` s cloudovým a lokálním backendem |
|
||||
| 📐 **Vložení** | `/v1/embeddings` pro vyhledávání a RAG pipelines |
|
||||
| 🎤 **Přepis zvuku** | `/v1/audio/transcriptions` (Whisper a další poskytovatelé) |
|
||||
| 🔊 **Převod textu na řeč** | `/v1/audio/speech` (více enginů/poskytovatelů) |
|
||||
| 🎬 **Generování videa** | `/v1/videos/generations` (pracovní postupy ComfyUI + SD WebUI) |
|
||||
| 🎵 **Hudební generace** | `/v1/music/generations` (pracovní postupy ComfyUI) |
|
||||
| 🛡️ **Moderování** | Bezpečnostní kontroly `/v1/moderations` |
|
||||
| 🔀 **Změna pořadí** | `/v1/rerank` pro hodnocení relevance |
|
||||
| 🔍 **Vyhledávání na webu** 🆕 | `/v1/search` — 5 poskytovatelů (Serper, Brave, Perplexity, Exa, Tavily), více než 6 500 zdarma/měsíc, automatické přepnutí na záložní systém, mezipaměť |
|
||||
|
||||
### 🛡️ Odolnost, bezpečnost a správa věcí veřejných
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
🔌 **Jističe** | Vypnutí/obnovení pro každý model s ovládáním prahových hodnot
|
||||
🎯 **Modely s ohledem na koncové body** | Vlastní modely deklarují podporované koncové body + formát API
|
||||
🛡️ **Stádo proti hromům** | Ochrana mutexu a semaforu při událostech opakování/rychlosti
|
||||
🧠 **Sémantická + podpisová mezipaměť** | Snížení nákladů/latence díky dvěma vrstvám mezipaměti
|
||||
⚡ **Žádost o idempotenci** | Okno ochrany proti duplikacím
|
||||
🔒 **Falšování otisků prstů pomocí TLS** | Otisk TLS podobný prohlížeči – **snižuje detekci botů a nahlašování účtů**
|
||||
🔏 **Porovnávání otisků prstů v CLI** | Shoduje se s nativními podpisy požadavků CLI – **snižuje riziko zablokování a zároveň zachovává IP adresu proxy**
|
||||
🌐 **Filtrování IP adres** | Ovládání seznamu povolených/blokovaných položek pro odhalená nasazení
|
||||
📊 **Upravitelné limity rychlosti** | Konfigurovatelné globální/na úrovni poskytovatele limity s perzistencí
|
||||
🔑 **Správa klíčů API a stanovení rozsahu** | Bezpečné vydávání/rotace klíčů a kontroly modelu/poskytovatele
|
||||
🛡️ **Chráněné `/models`** | Volitelné ověřování a skrytí poskytovatele pro katalog modelů
|
||||
| Funkce | Co to dělá |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔌 **Jističe** | Vypnutí/obnovení pro každý model s ovládáním prahových hodnot |
|
||||
| 🎯 **Modely s ohledem na koncové body** | Vlastní modely deklarují podporované koncové body + formát API |
|
||||
| 🛡️ **Stádo proti hromům** | Ochrana mutexu a semaforu při událostech opakování/rychlosti |
|
||||
| 🧠 **Sémantická + podpisová mezipaměť** | Snížení nákladů/latence díky dvěma vrstvám mezipaměti |
|
||||
| ⚡ **Žádost o idempotenci** | Okno ochrany proti duplikacím |
|
||||
| 🔒 **Falšování otisků prstů pomocí TLS** | Otisk TLS podobný prohlížeči – **snižuje detekci botů a nahlašování účtů** |
|
||||
| 🔏 **Porovnávání otisků prstů v CLI** | Shoduje se s nativními podpisy požadavků CLI – **snižuje riziko zablokování a zároveň zachovává IP adresu proxy** |
|
||||
| 🌐 **Filtrování IP adres** | Ovládání seznamu povolených/blokovaných položek pro odhalená nasazení |
|
||||
| 📊 **Upravitelné limity rychlosti** | Konfigurovatelné globální/na úrovni poskytovatele limity s perzistencí |
|
||||
| 🔑 **Správa klíčů API a stanovení rozsahu** | Bezpečné vydávání/rotace klíčů a kontroly modelu/poskytovatele |
|
||||
| 🛡️ **Chráněné `/models`** | Volitelné ověřování a skrytí poskytovatele pro katalog modelů |
|
||||
|
||||
### 📊 Pozorovatelnost a analytika
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
📝 **Žádost + protokolování proxy** | Úplné protokolování požadavků/odpovědí a proxy
|
||||
📋 **Sjednocený panel protokolů** | Zobrazení požadavků, proxy, auditu a konzole na jedné stránce
|
||||
🔍 **Vyžádat si telemetrii** | Latence p50/p95/p99 a trasování požadavků
|
||||
🏥 **Panel zdraví** | Doba provozuschopnosti, stavy jističů, uzamčení, statistiky mezipaměti
|
||||
💰 **Sledování nákladů** | Kontrola rozpočtu a přehled o cenách pro jednotlivé modely
|
||||
📈 **Analytické vizualizace** | Přehledy využití modelů/poskytovatelů a zobrazení trendů
|
||||
🧪 **Rámec hodnocení** | Testování zlaté sady s konfigurovatelnými strategiemi shody
|
||||
| Funkce | Co to dělá |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------- |
|
||||
| 📝 **Žádost + protokolování proxy** | Úplné protokolování požadavků/odpovědí a proxy |
|
||||
| 📋 **Sjednocený panel protokolů** | Zobrazení požadavků, proxy, auditu a konzole na jedné stránce |
|
||||
| 🔍 **Vyžádat si telemetrii** | Latence p50/p95/p99 a trasování požadavků |
|
||||
| 🏥 **Panel zdraví** | Doba provozuschopnosti, stavy jističů, uzamčení, statistiky mezipaměti |
|
||||
| 💰 **Sledování nákladů** | Kontrola rozpočtu a přehled o cenách pro jednotlivé modely |
|
||||
| 📈 **Analytické vizualizace** | Přehledy využití modelů/poskytovatelů a zobrazení trendů |
|
||||
| 🧪 **Rámec hodnocení** | Testování zlaté sady s konfigurovatelnými strategiemi shody |
|
||||
|
||||
### ☁️ Nasazení a platforma
|
||||
|
||||
Funkce | Co to dělá
|
||||
--- | ---
|
||||
🌐 **Nasazení kdekoli** | Localhost, VPS, Docker, cloudová prostředí
|
||||
💾 **Synchronizace s cloudem** | Synchronizace konfigurace přes cloud worker
|
||||
🔄 **Zálohování/Obnovení** | Toky exportu/importu a obnovy po havárii
|
||||
🧙 **Průvodce nástupem** | Průvodce prvním spuštěním
|
||||
🔧 **Panel nástrojů CLI** | Nastavení oblíbených kódovacích nástrojů jedním kliknutím
|
||||
🎮 **Modelové hřiště** | Otestujte libovolného poskytovatele/model/koncový bod z řídicího panelu
|
||||
🔏 **Přepínač otisků prstů v příkazovém řádku** | Porovnávání otisků prstů podle poskytovatele v Nastavení > Zabezpečení
|
||||
🌐 **i18n (30 jazyků)** | Plná jazyková podpora dashboardu a dokumentace s psaním zprava doleva
|
||||
📂 **Adresář vlastních dat** | Přepsání `DATA_DIR` pro umístění úložiště
|
||||
| Funkce | Co to dělá |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| 🌐 **Nasazení kdekoli** | Localhost, VPS, Docker, cloudová prostředí |
|
||||
| 💾 **Synchronizace s cloudem** | Synchronizace konfigurace přes cloud worker |
|
||||
| 🔄 **Zálohování/Obnovení** | Toky exportu/importu a obnovy po havárii |
|
||||
| 🧙 **Průvodce nástupem** | Průvodce prvním spuštěním |
|
||||
| 🔧 **Panel nástrojů CLI** | Nastavení oblíbených kódovacích nástrojů jedním kliknutím |
|
||||
| 🎮 **Modelové hřiště** | Otestujte libovolného poskytovatele/model/koncový bod z řídicího panelu |
|
||||
| 🔏 **Přepínač otisků prstů v příkazovém řádku** | Porovnávání otisků prstů podle poskytovatele v Nastavení > Zabezpečení |
|
||||
| 🌐 **i18n (30 jazyků)** | Plná jazyková podpora dashboardu a dokumentace s psaním zprava doleva |
|
||||
| 📂 **Adresář vlastních dat** | Přepsání `DATA_DIR` pro umístění úložiště |
|
||||
|
||||
### Hluboký pohled na funkce
|
||||
|
||||
@@ -1203,12 +1115,12 @@ Předinstalovaná sada „OmniRoute Golden Set“ obsahuje testovací případy
|
||||
|
||||
### Strategie hodnocení
|
||||
|
||||
Strategie | Popis | Příklad
|
||||
--- | --- | ---
|
||||
`exact` | Výstup se musí přesně shodovat | `"4"`
|
||||
`contains` | Výstup musí obsahovat podřetězec (bez rozlišení velkých a malých písmen) | `"Paris"`
|
||||
`regex` | Výstup musí odpovídat vzoru regulárních výrazů | `"1.*2.*3"`
|
||||
`custom` | Vlastní JS funkce vrací true/false | `(output) => output.length > 10`
|
||||
| Strategie | Popis | Příklad |
|
||||
| ---------- | ------------------------------------------------------------------------ | -------------------------------- |
|
||||
| `exact` | Výstup se musí přesně shodovat | `"4"` |
|
||||
| `contains` | Výstup musí obsahovat podřetězec (bez rozlišení velkých a malých písmen) | `"Paris"` |
|
||||
| `regex` | Výstup musí odpovídat vzoru regulárních výrazů | `"1.*2.*3"` |
|
||||
| `custom` | Vlastní JS funkce vrací true/false | `(output) => output.length > 10` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1240,9 +1152,6 @@ Užitečná API pro automatizaci:
|
||||
- `GET /api/mcp/audit`
|
||||
- `GET /api/mcp/audit/stats`
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🤝 Nastavení A2A (Agent2Agent)</b></summary>
|
||||
</details>
|
||||
@@ -1272,9 +1181,6 @@ Provozní uživatelské rozhraní:
|
||||
|
||||
- `/dashboard/a2a` pro pozorovatelnost úloh/stavů/streamů a akce kouření
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🧪 Komplexní validace protokolu</b></summary>
|
||||
</details>
|
||||
@@ -1291,9 +1197,6 @@ Tím se ověřuje:
|
||||
- A2A objevování/odesílání/streamování/získávání/zrušení
|
||||
- Křížová kontrola dat v auditu MCP a API pro správu úloh A2A
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>💳 Poskytovatelé předplatného</b></summary>
|
||||
</details>
|
||||
@@ -1369,9 +1272,6 @@ Models:
|
||||
gh/gemini-3-pro
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔑 Poskytovatelé klíčů API</b></summary>
|
||||
</details>
|
||||
@@ -1381,7 +1281,7 @@ Models:
|
||||
1. Registrace: [build.nvidia.com](https://build.nvidia.com)
|
||||
2. Získejte zdarma klíč API (včetně 1000 inferenčních kreditů)
|
||||
3. Ovládací panel → Přidat poskytovatele → NVIDIA NIM:
|
||||
- Klíč API: `nvapi-your-key`
|
||||
- Klíč API: `nvapi-your-key`
|
||||
|
||||
**Modely:** `nvidia/llama-3.3-70b-instruct` , `nvidia/mistral-7b-instruct` a více než 50 dalších
|
||||
|
||||
@@ -1413,9 +1313,6 @@ Models:
|
||||
|
||||
**Modely:** Získejte přístup k více než 100 modelům od všech hlavních poskytovatelů prostřednictvím jediného klíče API.
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>💰 Levní poskytovatelé (záložní)</b></summary>
|
||||
</details>
|
||||
@@ -1425,8 +1322,8 @@ Models:
|
||||
1. Registrace: [Zhipu AI](https://open.bigmodel.cn/)
|
||||
2. Získejte klíč API z kódovacího plánu
|
||||
3. Nástěnka → Přidat klíč API:
|
||||
- Poskytovatel: `glm`
|
||||
- Klíč API: `your-key`
|
||||
- Poskytovatel: `glm`
|
||||
- Klíč API: `your-key`
|
||||
|
||||
**Použití:** `glm/glm-4.7`
|
||||
|
||||
@@ -1452,9 +1349,6 @@ Models:
|
||||
|
||||
**Tip pro profesionály:** Fixních 9 $/měsíc za 10 milionů tokenů = efektivní náklady 0,90 $/1 milion!
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🆓 BEZPLATNÍ poskytovatelé (nouzové zálohování)</b></summary>
|
||||
</details>
|
||||
@@ -1498,9 +1392,6 @@ Models:
|
||||
kr/claude-haiku-4.5
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🎨 Vytvořte kombinace</b></summary>
|
||||
</details>
|
||||
@@ -1531,9 +1422,6 @@ Models:
|
||||
Cost: $0 forever!
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>🔧 Integrace s rozhraním příkazového řádku</b></summary>
|
||||
</details>
|
||||
@@ -1637,9 +1525,6 @@ opencode
|
||||
|
||||
> **Tip:** Do sekce `models` přidejte jakýkoli model dostupný ve vašem koncovém bodu OmniRoute `/v1/models` . Použijte formát `provider/model-id` z vašeho dashboardu OmniRoute.
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Řešení problémů
|
||||
@@ -1880,14 +1765,8 @@ Chcete-li získat přístup k kriterii pověření, můžete použít adresu **U
|
||||
|
||||
> Toto řešení funguje na základě autorizačního kódu na adrese URL a nezávislého přesměrování přesměrování nebo jiného.
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
## 🛠️ Technologický stack
|
||||
|
||||
<details>
|
||||
@@ -1909,28 +1788,25 @@ Chcete-li získat přístup k kriterii pověření, můžete použít adresu **U
|
||||
- **Docker** : [hub.docker.com/r/diegosouzapw/omniroute](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
- **Odolnost** : Jistič, exponenciální odstavení, ochrana proti hromům, falešné TLS, automatické kombinované samoopravování
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📖 Dokumentace
|
||||
|
||||
Dokument | Popis
|
||||
--- | ---
|
||||
[Uživatelská příručka](docs/USER_GUIDE.md) | Poskytovatelé, kombinace, integrace CLI, nasazení
|
||||
[Referenční informace k API](docs/API_REFERENCE.md) | Všechny koncové body s příklady
|
||||
[MCP server](open-sse/mcp-server/README.md) | 16 nástrojů MCP, konfigurace IDE, klienti Python/TS/Go
|
||||
[Server A2A](src/lib/a2a/README.md) | Protokol JSON-RPC 2.0, dovednosti, streamování, správa úloh
|
||||
[Auto-Combo Engine](docs/auto-combo.md) | 6faktorové bodování, balíčky režimů, samoléčba
|
||||
[Odstraňování problémů](docs/TROUBLESHOOTING.md) | Běžné problémy a jejich řešení
|
||||
[Architektura](docs/ARCHITECTURE.md) | Architektura a interní prvky systému
|
||||
[Přispívání](CONTRIBUTING.md) | Nastavení a pokyny pro vývoj
|
||||
[Specifikace OpenAPI](docs/openapi.yaml) | Specifikace OpenAPI 3.0
|
||||
[Bezpečnostní zásady](SECURITY.md) | Hlášení zranitelností a bezpečnostní postupy
|
||||
[Nasazení virtuálního počítače](docs/VM_DEPLOYMENT_GUIDE.md) | Kompletní průvodce: Nastavení virtuálního počítače + nginx + Cloudflare
|
||||
[Galerie funkcí](docs/FEATURES.md) | Vizuální prohlídka řídicího panelu se snímky obrazovky
|
||||
[Kontrolní seznam vydání](docs/RELEASE_CHECKLIST.md) | Kroky ověření před vydáním
|
||||
| Dokument | Popis |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| [Uživatelská příručka](docs/USER_GUIDE.md) | Poskytovatelé, kombinace, integrace CLI, nasazení |
|
||||
| [Referenční informace k API](docs/API_REFERENCE.md) | Všechny koncové body s příklady |
|
||||
| [MCP server](open-sse/mcp-server/README.md) | 16 nástrojů MCP, konfigurace IDE, klienti Python/TS/Go |
|
||||
| [Server A2A](src/lib/a2a/README.md) | Protokol JSON-RPC 2.0, dovednosti, streamování, správa úloh |
|
||||
| [Auto-Combo Engine](docs/auto-combo.md) | 6faktorové bodování, balíčky režimů, samoléčba |
|
||||
| [Odstraňování problémů](docs/TROUBLESHOOTING.md) | Běžné problémy a jejich řešení |
|
||||
| [Architektura](docs/ARCHITECTURE.md) | Architektura a interní prvky systému |
|
||||
| [Přispívání](CONTRIBUTING.md) | Nastavení a pokyny pro vývoj |
|
||||
| [Specifikace OpenAPI](docs/openapi.yaml) | Specifikace OpenAPI 3.0 |
|
||||
| [Bezpečnostní zásady](SECURITY.md) | Hlášení zranitelností a bezpečnostní postupy |
|
||||
| [Nasazení virtuálního počítače](docs/VM_DEPLOYMENT_GUIDE.md) | Kompletní průvodce: Nastavení virtuálního počítače + nginx + Cloudflare |
|
||||
| [Galerie funkcí](docs/FEATURES.md) | Vizuální prohlídka řídicího panelu se snímky obrazovky |
|
||||
| [Kontrolní seznam vydání](docs/RELEASE_CHECKLIST.md) | Kroky ověření před vydáním |
|
||||
|
||||
---
|
||||
|
||||
@@ -1938,14 +1814,14 @@ Dokument | Popis
|
||||
|
||||
OmniRoute má **v plánu více než 210 funkcí** v několika fázích vývoje. Zde jsou klíčové oblasti:
|
||||
|
||||
Kategorie | Plánované funkce | Hlavní body
|
||||
--- | --- | ---
|
||||
🧠 **Směrování a inteligence** | 25+ | Směrování s nejnižší latencí, směrování založené na tagech, kontrola kvót před výstupem, výběr účtu P2C
|
||||
🔒 **Zabezpečení a dodržování předpisů** | 20+ | Zpevnění SSRF, maskování přihlašovacích údajů, limit rychlosti pro každý koncový bod, stanovení rozsahu klíčů pro správu
|
||||
📊 **Pozorovatelnost** | 15+ | Integrace OpenTelemetry, sledování kvót v reálném čase, sledování nákladů podle modelu
|
||||
🔄 **Integrace poskytovatelů** | 20+ | Dynamický registr modelů, doba zchlazení poskytovatelů, Codex pro více účtů, analýza kvót Copilota
|
||||
⚡ **Výkon** | 15+ | Dvojitá vrstva mezipaměti, mezipaměť výzev, mezipaměť odpovědí, udržování streamování, dávkové API
|
||||
🌐 **Ekosystém** | 10+ | WebSocket API, horké opětovné načítání konfigurace, distribuované úložiště konfigurace, komerční režim
|
||||
| Kategorie | Plánované funkce | Hlavní body |
|
||||
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🧠 **Směrování a inteligence** | 25+ | Směrování s nejnižší latencí, směrování založené na tagech, kontrola kvót před výstupem, výběr účtu P2C |
|
||||
| 🔒 **Zabezpečení a dodržování předpisů** | 20+ | Zpevnění SSRF, maskování přihlašovacích údajů, limit rychlosti pro každý koncový bod, stanovení rozsahu klíčů pro správu |
|
||||
| 📊 **Pozorovatelnost** | 15+ | Integrace OpenTelemetry, sledování kvót v reálném čase, sledování nákladů podle modelu |
|
||||
| 🔄 **Integrace poskytovatelů** | 20+ | Dynamický registr modelů, doba zchlazení poskytovatelů, Codex pro více účtů, analýza kvót Copilota |
|
||||
| ⚡ **Výkon** | 15+ | Dvojitá vrstva mezipaměti, mezipaměť výzev, mezipaměť odpovědí, udržování streamování, dávkové API |
|
||||
| 🌐 **Ekosystém** | 10+ | WebSocket API, horké opětovné načítání konfigurace, distribuované úložiště konfigurace, komerční režim |
|
||||
|
||||
### 🔜 Již brzy
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+79
-28
@@ -819,24 +819,28 @@ Quando minimizado, o OmniRoute fica na bandeja do sistema com ações rápidas:
|
||||
|
||||
## 💰 Preços Resumidos
|
||||
|
||||
| Tier | Provedor | Custo | Reset de Cota | Melhor Para |
|
||||
| ----------------- | ----------------- | ---------------------------- | ----------------- | ----------------------- |
|
||||
| **💳 ASSINATURA** | Claude Code (Pro) | $20/mês | 5h + semanal | Já é assinante |
|
||||
| | Codex (Plus/Pro) | $20-200/mês | 5h + semanal | Usuários OpenAI |
|
||||
| | Gemini CLI | **GRATUITO** | 180K/mês + 1K/dia | Todos! |
|
||||
| | GitHub Copilot | $10-19/mês | Mensal | Usuários GitHub |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **GRATUITO** (1000 créditos) | Único | Testes gratuitos |
|
||||
| | DeepSeek | Por uso | Nenhum | Melhor preço/qualidade |
|
||||
| | Groq | Tier gratuito + pago | Limitado | Inferência ultra-rápida |
|
||||
| | xAI (Grok) | Por uso | Nenhum | Modelos Grok |
|
||||
| | Mistral | Tier gratuito + pago | Limitado | IA Europeia |
|
||||
| | OpenRouter | Por uso | Nenhum | 100+ modelos |
|
||||
| **💰 BARATO** | GLM-4.7 | $0.6/1M | Diário 10h | Backup econômico |
|
||||
| | MiniMax M2.1 | $0.2/1M | Rotativo 5h | Opção mais barata |
|
||||
| | Kimi K2 | $9/mês fixo | 10M tokens/mês | Custo previsível |
|
||||
| **🆓 GRATUITO** | iFlow | $0 | Ilimitado | 8 modelos gratuitos |
|
||||
| | Qwen | $0 | Ilimitado | 3 modelos gratuitos |
|
||||
| | Kiro | $0 | Ilimitado | Claude gratuito |
|
||||
| Tier | Provedor | Custo | Reset de Cota | Melhor Para |
|
||||
| ----------------- | ----------------- | ---------------------------- | ----------------- | ------------------------------ |
|
||||
| **💳 ASSINATURA** | Claude Code (Pro) | $20/mês | 5h + semanal | Já é assinante |
|
||||
| | Codex (Plus/Pro) | $20-200/mês | 5h + semanal | Usuários OpenAI |
|
||||
| | Gemini CLI | **GRATUITO** | 180K/mês + 1K/dia | Todos! |
|
||||
| | GitHub Copilot | $10-19/mês | Mensal | Usuários GitHub |
|
||||
| **🔑 API KEY** | NVIDIA NIM | **GRATUITO** (1000 créditos) | Único | Testes gratuitos |
|
||||
| | DeepSeek | Por uso | Nenhum | Melhor preço/qualidade |
|
||||
| | Groq | Tier gratuito + pago | Limitado | Inferência ultra-rápida |
|
||||
| | xAI (Grok) | Por uso | Nenhum | Modelos Grok |
|
||||
| | Mistral | Tier gratuito + pago | Limitado | IA Europeia |
|
||||
| | OpenRouter | Por uso | Nenhum | 100+ modelos |
|
||||
| **💰 BARATO** | GLM-4.7 | $0.6/1M | Diário 10h | Backup econômico |
|
||||
| | MiniMax M2.1 | $0.2/1M | Rotativo 5h | Opção mais barata |
|
||||
| | Kimi K2 | $9/mês fixo | 10M tokens/mês | Custo previsível |
|
||||
| **🆓 GRATUITO** | iFlow | $0 | Ilimitado | 8 modelos gratuitos |
|
||||
| | Qwen | $0 | Ilimitado | 3 modelos gratuitos |
|
||||
| | Kiro | $0 | Ilimitado | Claude gratuito |
|
||||
| | LongCat 🆕 | **$0** (50M tok/dia 🔥) | 1 req/s | Maior cota grátis do mundo |
|
||||
| | Pollinations 🆕 | **$0** (sem chave API) | 1 req/15s | GPT-5, Claude, DeepSeek, Llama |
|
||||
| | Cloudflare AI 🆕 | **$0** (10K Neurons/dia) | ~150 resp/dia | 50+ modelos, edge global |
|
||||
| | Scaleway AI 🆕 | **$0** (1M tokens total) | Limitado por taxa | EU/GDPR, Qwen3 235B, Llama 70B |
|
||||
|
||||
**💡 Dica Pro:** Comece com Gemini CLI (180K grátis/mês) + iFlow (ilimitado grátis) = $0 de custo!
|
||||
|
||||
@@ -879,16 +883,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
|
||||
|
||||
@@ -1223,6 +1227,53 @@ Modelos:
|
||||
kr/claude-haiku-4.5
|
||||
```
|
||||
|
||||
### LongCat AI (GRATUITO 50M tokens/dia!) 🆕
|
||||
|
||||
1. Cadastre-se: [longcat.chat](https://longcat.chat) com e-mail ou telefone
|
||||
2. Gere uma chave de API gratuita
|
||||
3. Dashboard → Adicionar Provedor → LongCat
|
||||
|
||||
**Modelos:**
|
||||
|
||||
- `lc/LongCat-Flash-Lite` — **50M tokens/dia** 💥 (maior cota gratuita do mundo!)
|
||||
- `lc/LongCat-Flash-Chat` — 500K tokens/dia
|
||||
- `lc/LongCat-Flash-Thinking` — 500K tokens/dia (raciocínio)
|
||||
|
||||
> 100% gratuito durante o beta público. Reset diário à meia-noite UTC.
|
||||
|
||||
### Pollinations AI (SEM CHAVE NECESSÁRIA!) 🆕
|
||||
|
||||
1. Adicione o provedor Pollinations no Dashboard
|
||||
2. Deixe o campo de chave API vazio (ou coloque qualquer string)
|
||||
3. Comece a usar imediatamente!
|
||||
|
||||
**Modelos via `pol/`:** `openai` (GPT-5), `claude`, `gemini`, `deepseek`, `llama` (Llama 4)
|
||||
|
||||
> Sem cadastro, sem chave, sem cartão de crédito. 1 req/15s ilimitado.
|
||||
|
||||
### Cloudflare Workers AI (GRATUITO 10K Neurons/dia!) 🆕
|
||||
|
||||
1. Cadastre-se: [dash.cloudflare.com](https://dash.cloudflare.com)
|
||||
2. Gere um API Token em Profile → API Tokens
|
||||
3. Copie seu Account ID (coluna direita do dashboard)
|
||||
4. Dashboard → Adicionar Provedor → Cloudflare AI
|
||||
- API Key: seu token
|
||||
- Account ID: seu account ID
|
||||
|
||||
**Modelos via `cf/`:** `@cf/meta/llama-3.3-70b-instruct`, `@cf/google/gemma-3-12b-it`, 50+ mais
|
||||
|
||||
> 10K Neurons/dia ≈ 150 respostas de LLM ou 500s de transcrição Whisper gratuita!
|
||||
|
||||
### Scaleway AI (1M tokens gratuitos!) 🆕
|
||||
|
||||
1. Cadastre-se: [console.scaleway.com](https://console.scaleway.com)
|
||||
2. Gere uma chave de API IAM
|
||||
3. Dashboard → Adicionar Provedor → Scaleway
|
||||
|
||||
**Modelos via `scw/`:** `qwen3-235b-a22b-instruct-2507` (Qwen3 235B!), `llama-3.1-70b-instruct`
|
||||
|
||||
> 1M tokens gratuitos para novas contas. Dados processados na 🇫🇷 França (EU/GDPR).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -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.9
|
||||
version: 3.0.0-rc.9
|
||||
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,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface EmbeddingProvider {
|
||||
}
|
||||
|
||||
export interface EmbeddingProviderNodeRow {
|
||||
id?: string;
|
||||
prefix: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RegistryEntry {
|
||||
executor: string;
|
||||
baseUrl?: string;
|
||||
baseUrls?: string[];
|
||||
/** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */
|
||||
testKeyBaseUrl?: string;
|
||||
responsesBaseUrl?: string;
|
||||
urlSuffix?: string;
|
||||
urlBuilder?: (base: string, model: string, stream: boolean) => string;
|
||||
@@ -495,6 +497,41 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"opencode-go": {
|
||||
id: "opencode-go",
|
||||
alias: "opencode-go",
|
||||
format: "openai",
|
||||
executor: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
// (#532) Key validation must hit the main zen endpoint (same key works for both tiers)
|
||||
testKeyBaseUrl: "https://opencode.ai/zen/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
models: [
|
||||
{ id: "glm-5", name: "GLM-5" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
],
|
||||
},
|
||||
|
||||
"opencode-zen": {
|
||||
id: "opencode-zen",
|
||||
alias: "opencode-zen",
|
||||
format: "openai",
|
||||
executor: "opencode",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
models: [
|
||||
{ id: "minimax-m2.5-free", name: "MiniMax M2.5 Free" },
|
||||
{ id: "big-pickle", name: "Big Pickle" },
|
||||
{ id: "gpt-5-nano", name: "GPT 5 Nano" },
|
||||
],
|
||||
},
|
||||
|
||||
openrouter: {
|
||||
id: "openrouter",
|
||||
alias: "openrouter",
|
||||
@@ -883,6 +920,12 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct-Turbo-Free", name: "Llama 3.3 70B Turbo (🆓 Free)" },
|
||||
{ id: "meta-llama/Llama-Vision-Free", name: "Llama Vision (🆓 Free)" },
|
||||
{
|
||||
id: "deepseek-ai/DeepSeek-R1-Distill-Llama-70B-Free",
|
||||
name: "DeepSeek R1 Distill 70B (🆓 Free)",
|
||||
},
|
||||
{ id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", name: "Llama 3.3 70B Turbo" },
|
||||
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
|
||||
{ id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" },
|
||||
@@ -1125,6 +1168,199 @@ 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,
|
||||
},
|
||||
|
||||
// ── New Free Providers (2026) ─────────────────────────────────────────────
|
||||
|
||||
longcat: {
|
||||
id: "longcat",
|
||||
alias: "lc",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
// (#536) Correct OpenAI-compatible base URL — was longcat.chat/api/v1/chat/completions
|
||||
// which is the chat endpoint directly, not the base. Key validation and routing must
|
||||
// use https://api.longcat.chat/openai which resolves /v1/models and /v1/chat/completions
|
||||
baseUrl: "https://api.longcat.chat/openai",
|
||||
authType: "apikey",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer",
|
||||
// Free tier: 50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — 100% free while public beta
|
||||
models: [
|
||||
{ id: "LongCat-Flash-Lite", name: "LongCat Flash-Lite (50M tok/day 🆓)" },
|
||||
{ id: "LongCat-Flash-Chat", name: "LongCat Flash-Chat (500K tok/day 🆓)" },
|
||||
{ id: "LongCat-Flash-Thinking", name: "LongCat Flash-Thinking (500K tok/day 🆓)" },
|
||||
{ id: "LongCat-Flash-Thinking-2601", name: "LongCat Flash-Thinking-2601 (🆓)" },
|
||||
{ id: "LongCat-Flash-Omni-2603", name: "LongCat Flash-Omni-2603 (🆓)" },
|
||||
],
|
||||
},
|
||||
|
||||
pollinations: {
|
||||
id: "pollinations",
|
||||
alias: "pol",
|
||||
format: "openai",
|
||||
executor: "pollinations",
|
||||
// No API key required for basic use. Proxy to GPT-5, Claude, Gemini, DeepSeek, Llama 4.
|
||||
baseUrl: "https://text.pollinations.ai/openai/chat/completions",
|
||||
authType: "apikey", // Optional — works without one too
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "openai", name: "GPT-5 via Pollinations (🆓)" },
|
||||
{ id: "claude", name: "Claude via Pollinations (🆓)" },
|
||||
{ id: "gemini", name: "Gemini via Pollinations (🆓)" },
|
||||
{ id: "deepseek", name: "DeepSeek V3 via Pollinations (🆓)" },
|
||||
{ id: "llama", name: "Llama 4 via Pollinations (🆓)" },
|
||||
{ id: "mistral", name: "Mistral via Pollinations (🆓)" },
|
||||
],
|
||||
},
|
||||
|
||||
puter: {
|
||||
id: "puter",
|
||||
alias: "pu",
|
||||
format: "openai",
|
||||
executor: "puter",
|
||||
// OpenAI-compatible gateway with 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen…)
|
||||
// Auth: Bearer <puter_auth_token> from puter.com/dashboard → Copy Auth Token
|
||||
// Model IDs use provider/model-name format for non-OpenAI models.
|
||||
// Only chat completions (incl. streaming) are available via REST.
|
||||
// Image gen, TTS, STT, video are puter.js SDK-only (browser).
|
||||
baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
// OpenAI — use bare IDs
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o Mini (🆓 Puter)" },
|
||||
{ id: "gpt-4o", name: "GPT-4o (Puter)" },
|
||||
{ id: "gpt-4.1", name: "GPT-4.1 (Puter)" },
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini (Puter)" },
|
||||
{ id: "gpt-5-nano", name: "GPT-5 Nano (Puter)" },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini (Puter)" },
|
||||
{ id: "gpt-5", name: "GPT-5 (Puter)" },
|
||||
{ id: "o3-mini", name: "OpenAI o3-mini (Puter)" },
|
||||
{ id: "o3", name: "OpenAI o3 (Puter)" },
|
||||
{ id: "o4-mini", name: "OpenAI o4-mini (Puter)" },
|
||||
// Anthropic Claude — use bare IDs (confirmed working)
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Puter)" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Puter)" },
|
||||
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Puter)" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (Puter)" },
|
||||
{ id: "claude-opus-4", name: "Claude Opus 4 (Puter)" },
|
||||
// Google Gemini — use google/ prefix (confirmed working)
|
||||
{ id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash (Puter)" },
|
||||
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Puter)" },
|
||||
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro (Puter)" },
|
||||
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash (Puter)" },
|
||||
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro (Puter)" },
|
||||
// DeepSeek — use deepseek/ prefix (confirmed working)
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat (Puter)" },
|
||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1 (Puter)" },
|
||||
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek V3.2 (Puter)" },
|
||||
// xAI Grok — use x-ai/ prefix
|
||||
{ id: "x-ai/grok-3", name: "Grok 3 (Puter)" },
|
||||
{ id: "x-ai/grok-3-mini", name: "Grok 3 Mini (Puter)" },
|
||||
{ id: "x-ai/grok-4", name: "Grok 4 (Puter)" },
|
||||
{ id: "x-ai/grok-4-fast", name: "Grok 4 Fast (Puter)" },
|
||||
// Meta Llama — bare IDs (confirmed ✅)
|
||||
{ id: "llama-4-scout", name: "Llama 4 Scout (Puter)" },
|
||||
{ id: "llama-4-maverick", name: "Llama 4 Maverick (Puter)" },
|
||||
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B (Puter)" },
|
||||
// Mistral — bare IDs (confirmed ✅)
|
||||
{ id: "mistral-small-latest", name: "Mistral Small (Puter)" },
|
||||
{ id: "mistral-medium-latest", name: "Mistral Medium (Puter)" },
|
||||
{ id: "open-mistral-nemo", name: "Mistral Nemo (Puter)" },
|
||||
// Qwen — use qwen/ prefix (confirmed ✅)
|
||||
{ id: "qwen/qwen3-235b-a22b", name: "Qwen3 235B (Puter)" },
|
||||
{ id: "qwen/qwen3-32b", name: "Qwen3 32B (Puter)" },
|
||||
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B (Puter)" },
|
||||
],
|
||||
passthroughModels: true, // 500+ models available — users can type any Puter model ID
|
||||
},
|
||||
|
||||
"cloudflare-ai": {
|
||||
id: "cloudflare-ai",
|
||||
alias: "cf",
|
||||
format: "openai",
|
||||
executor: "cloudflare-ai",
|
||||
// URL is dynamic: uses accountId from credentials. The executor builds it.
|
||||
baseUrl: "https://api.cloudflare.com/client/v4/accounts",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
// 10K Neurons/day free: ~150 LLM responses or 500s Whisper audio — global edge
|
||||
models: [
|
||||
{ id: "@cf/meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B (🆓 ~150 resp/day)" },
|
||||
{ id: "@cf/meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B (🆓)" },
|
||||
{ id: "@cf/google/gemma-3-12b-it", name: "Gemma 3 12B (🆓)" },
|
||||
{ id: "@cf/mistral/mistral-7b-instruct-v0.2-lora", name: "Mistral 7B (🆓)" },
|
||||
{ id: "@cf/qwen/qwen2.5-coder-15b-instruct", name: "Qwen 2.5 Coder 15B (🆓)" },
|
||||
{ id: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", name: "DeepSeek R1 Distill 32B (🆓)" },
|
||||
],
|
||||
},
|
||||
|
||||
scaleway: {
|
||||
id: "scaleway",
|
||||
alias: "scw",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.scaleway.ai/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
// 1M tokens free for new accounts — EU/GDPR (Paris), no credit card needed under limit
|
||||
models: [
|
||||
{ id: "qwen3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B (1M free tok 🆓)" },
|
||||
{ id: "llama-3.1-70b-instruct", name: "Llama 3.1 70B (🆓 EU)" },
|
||||
{ id: "llama-3.1-8b-instruct", name: "Llama 3.1 8B (🆓 EU)" },
|
||||
{ id: "mistral-small-3.2-24b-instruct-2506", name: "Mistral Small 3.2 (🆓 EU)" },
|
||||
{ id: "deepseek-v3-0324", name: "DeepSeek V3 (🆓 EU)" },
|
||||
{ id: "gpt-oss-120b", name: "GPT-OSS 120B (🆓 EU)" },
|
||||
],
|
||||
},
|
||||
|
||||
aimlapi: {
|
||||
id: "aimlapi",
|
||||
alias: "aiml",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.aimlapi.com/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
// $0.025/day free credits — 200+ models via single aggregator endpoint
|
||||
models: [
|
||||
{ id: "gpt-4o", name: "GPT-4o (via AI/ML API)" },
|
||||
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet (via AI/ML API)" },
|
||||
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro (via AI/ML API)" },
|
||||
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", name: "Llama 3.1 70B (via AI/ML API)" },
|
||||
{ id: "deepseek-chat", name: "DeepSeek Chat (via AI/ML API)" },
|
||||
{ id: "mistral-large-latest", name: "Mistral Large (via AI/ML API)" },
|
||||
],
|
||||
passthroughModels: true,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Generator Functions ───────────────────────────────────────────────────
|
||||
|
||||
@@ -44,12 +44,28 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
// stale/wrong client-side values causing 404/403 from Cloud Code endpoints.
|
||||
// Opt-in escape hatch: set OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=1.
|
||||
const projectId =
|
||||
allowBodyProjectOverride && bodyProjectId ? bodyProjectId : credentialsProjectId || bodyProjectId;
|
||||
allowBodyProjectOverride && bodyProjectId
|
||||
? bodyProjectId
|
||||
: credentialsProjectId || bodyProjectId;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth so OmniRoute can fetch your real Cloud Code project (loadCodeAssist)."
|
||||
);
|
||||
// (#489) Return a structured error instead of throwing — gives the client a clear signal
|
||||
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
|
||||
const errorMsg =
|
||||
"Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers → Antigravity so OmniRoute can fetch your Cloud Code project.";
|
||||
const errorBody = {
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: "oauth_missing_project_id",
|
||||
code: "missing_project_id",
|
||||
},
|
||||
};
|
||||
const resp = new Response(JSON.stringify(errorBody), {
|
||||
status: 422,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Returning a Response object signals the executor to stop and forward it
|
||||
return resp as unknown as never;
|
||||
}
|
||||
|
||||
// Fix contents for Claude models via Antigravity
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
/**
|
||||
* CloudflareAIExecutor — handles dynamic URL construction with accountId.
|
||||
* Cloudflare Workers AI uses the authenticated user's account ID in the URL.
|
||||
*
|
||||
* URL pattern: https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions
|
||||
* Auth: Bearer <API Token>
|
||||
* Docs: https://developers.cloudflare.com/workers-ai/
|
||||
*
|
||||
* Free tier: 10,000 Neurons/day = ~150 LLM responses or 500s Whisper audio
|
||||
* API Token: dash.cloudflare.com/profile/api-tokens
|
||||
* Account ID: right sidebar of dash.cloudflare.com
|
||||
*/
|
||||
export class CloudflareAIExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("cloudflare-ai", PROVIDERS["cloudflare-ai"] || { format: "openai" });
|
||||
}
|
||||
|
||||
buildUrl(_model: string, _stream: boolean, _urlIndex = 0, credentials: any = null): string {
|
||||
// Account ID can be stored in providerSpecificData or at top level credentials
|
||||
const accountId =
|
||||
credentials?.providerSpecificData?.accountId ||
|
||||
credentials?.accountId ||
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error(
|
||||
"Cloudflare Workers AI requires an Account ID. " +
|
||||
"Add it in provider settings under 'Account ID'. " +
|
||||
"Find it at: https://dash.cloudflare.com (right sidebar)."
|
||||
);
|
||||
}
|
||||
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: any, stream = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${credentials.apiKey || credentials.accessToken}`,
|
||||
};
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(_model: string, body: any, _stream: boolean, _credentials: any): any {
|
||||
// Cloudflare uses full model paths like @cf/meta/llama-3.3-70b-instruct
|
||||
// No transformation needed — user sends the full Cloudflare model path.
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export default CloudflareAIExecutor;
|
||||
@@ -3,6 +3,112 @@ import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
||||
|
||||
// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ────────────────────────
|
||||
// Codex has two independent quota pools: "codex" (standard) and "spark" (premium).
|
||||
// Exhausting one should NOT block requests to the other.
|
||||
// Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex)
|
||||
|
||||
/**
|
||||
* Maps model name substrings to their rate-limit scope.
|
||||
* Checked in order — first match wins.
|
||||
*/
|
||||
const CODEX_SCOPE_PATTERNS: Array<{ pattern: string; scope: "codex" | "spark" }> = [
|
||||
{ pattern: "codex-spark", scope: "spark" },
|
||||
{ pattern: "spark", scope: "spark" },
|
||||
{ pattern: "codex", scope: "codex" },
|
||||
{ pattern: "gpt-5", scope: "codex" }, // gpt-5.2-codex, gpt-5.3-codex, etc.
|
||||
];
|
||||
|
||||
/**
|
||||
* T09: Determine the rate-limit scope for a Codex model.
|
||||
* Use this key as the suffix for per-scope rate limit state:
|
||||
* `${accountId}:${getModelScope(model)}`
|
||||
*
|
||||
* @param model - The Codex model ID (e.g. "gpt-5.3-codex", "codex-spark-mini")
|
||||
* @returns "codex" | "spark"
|
||||
*/
|
||||
export function getCodexModelScope(model: string): "codex" | "spark" {
|
||||
const lower = model.toLowerCase();
|
||||
for (const { pattern, scope } of CODEX_SCOPE_PATTERNS) {
|
||||
if (lower.includes(pattern)) return scope;
|
||||
}
|
||||
return "codex"; // default scope
|
||||
}
|
||||
|
||||
/**
|
||||
* T09: Get the scope-keyed rate limit identifier for an account+model combination.
|
||||
* Use this as the key for rateLimitState maps to ensure scope isolation.
|
||||
*/
|
||||
export function getCodexRateLimitKey(accountId: string, model: string): string {
|
||||
return `${accountId}:${getCodexModelScope(model)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* T03: Parsed quota snapshot from Codex response headers.
|
||||
* Codex includes per-account usage windows that allow precise reset scheduling.
|
||||
* Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown)
|
||||
*/
|
||||
export interface CodexQuotaSnapshot {
|
||||
usage5h: number; // tokens used in 5h window
|
||||
limit5h: number; // token limit for 5h window
|
||||
resetAt5h: string | null; // ISO timestamp when 5h window resets
|
||||
usage7d: number; // tokens used in 7d window
|
||||
limit7d: number; // token limit for 7d window
|
||||
resetAt7d: string | null; // ISO timestamp when 7d window resets
|
||||
}
|
||||
|
||||
/**
|
||||
* T03: Parse Codex-specific quota headers from a provider response.
|
||||
* Returns null if none of the relevant headers are present.
|
||||
*
|
||||
* Extracts:
|
||||
* x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at
|
||||
* x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at
|
||||
*/
|
||||
export function parseCodexQuotaHeaders(headers: Headers): CodexQuotaSnapshot | null {
|
||||
const usage5h = headers.get("x-codex-5h-usage");
|
||||
const limit5h = headers.get("x-codex-5h-limit");
|
||||
const resetAt5h = headers.get("x-codex-5h-reset-at");
|
||||
const usage7d = headers.get("x-codex-7d-usage");
|
||||
const limit7d = headers.get("x-codex-7d-limit");
|
||||
const resetAt7d = headers.get("x-codex-7d-reset-at");
|
||||
|
||||
// Return null if none of the quota headers are present (not a quota-aware response)
|
||||
if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
usage5h: usage5h ? parseFloat(usage5h) : 0,
|
||||
limit5h: limit5h ? parseFloat(limit5h) : Infinity,
|
||||
resetAt5h: resetAt5h ?? null,
|
||||
usage7d: usage7d ? parseFloat(usage7d) : 0,
|
||||
limit7d: limit7d ? parseFloat(limit7d) : Infinity,
|
||||
resetAt7d: resetAt7d ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* T03: Get the soonest quota reset time from a CodexQuotaSnapshot.
|
||||
* 7d window takes priority (wider window, harder limit) but we use whichever
|
||||
* is further in the future to avoid releasing the block too early.
|
||||
*
|
||||
* @returns Unix timestamp (ms) of the soonest effective reset, or null
|
||||
*/
|
||||
export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null {
|
||||
const times: number[] = [];
|
||||
if (quota.resetAt7d) {
|
||||
const t = new Date(quota.resetAt7d).getTime();
|
||||
if (!isNaN(t) && t > Date.now()) times.push(t);
|
||||
}
|
||||
if (quota.resetAt5h) {
|
||||
const t = new Date(quota.resetAt5h).getTime();
|
||||
if (!isNaN(t) && t > Date.now()) times.push(t);
|
||||
}
|
||||
if (times.length === 0) return null;
|
||||
return Math.max(...times); // Use furthest-out reset to avoid premature unblock
|
||||
}
|
||||
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ import { KiroExecutor } from "./kiro.ts";
|
||||
import { CodexExecutor } from "./codex.ts";
|
||||
import { CursorExecutor } from "./cursor.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import { PollinationsExecutor } from "./pollinations.ts";
|
||||
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
import { OpencodeExecutor } from "./opencode.ts";
|
||||
import { PuterExecutor } from "./puter.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -16,6 +20,14 @@ const executors = {
|
||||
codex: new CodexExecutor(),
|
||||
cursor: new CursorExecutor(),
|
||||
cu: new CursorExecutor(), // Alias for cursor
|
||||
pollinations: new PollinationsExecutor(),
|
||||
pol: new PollinationsExecutor(), // Alias
|
||||
"cloudflare-ai": new CloudflareAIExecutor(),
|
||||
cf: new CloudflareAIExecutor(), // Alias
|
||||
"opencode-zen": new OpencodeExecutor("opencode-zen"),
|
||||
"opencode-go": new OpencodeExecutor("opencode-go"),
|
||||
puter: new PuterExecutor(),
|
||||
pu: new PuterExecutor(), // Alias
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -39,3 +51,7 @@ export { KiroExecutor } from "./kiro.ts";
|
||||
export { CodexExecutor } from "./codex.ts";
|
||||
export { CursorExecutor } from "./cursor.ts";
|
||||
export { DefaultExecutor } from "./default.ts";
|
||||
export { PollinationsExecutor } from "./pollinations.ts";
|
||||
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
export { OpencodeExecutor } from "./opencode.ts";
|
||||
export { PuterExecutor } from "./puter.ts";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
|
||||
export class OpencodeExecutor extends BaseExecutor {
|
||||
_requestFormat: string | null = null;
|
||||
|
||||
constructor(provider: string) {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
|
||||
try {
|
||||
return await super.execute(input);
|
||||
} finally {
|
||||
this._requestFormat = null;
|
||||
}
|
||||
}
|
||||
|
||||
buildUrl(
|
||||
model: string,
|
||||
stream: boolean,
|
||||
urlIndex = 0,
|
||||
credentials: ProviderCredentials | null = null
|
||||
) {
|
||||
void urlIndex;
|
||||
void credentials;
|
||||
|
||||
const base = this.config.baseUrl;
|
||||
switch (this._requestFormat) {
|
||||
case "claude":
|
||||
return `${base}/messages`;
|
||||
case "openai-responses":
|
||||
return `${base}/responses`;
|
||||
case "gemini":
|
||||
return `${base}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||
default:
|
||||
return `${base}/chat/completions`;
|
||||
}
|
||||
}
|
||||
|
||||
buildHeaders(credentials: ProviderCredentials | null, stream = true) {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (this._requestFormat === "claude") {
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
/**
|
||||
* PollinationsExecutor — handles optional API key auth.
|
||||
* Pollinations AI works WITHOUT any API key for basic use (1 req/15s).
|
||||
* If an API key is provided, higher rate limits apply.
|
||||
*
|
||||
* Endpoint: https://text.pollinations.ai/openai/chat/completions
|
||||
* Docs: https://pollinations.ai/docs
|
||||
*/
|
||||
export class PollinationsExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("pollinations", PROVIDERS["pollinations"] || { format: "openai" });
|
||||
}
|
||||
|
||||
buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials = null): string {
|
||||
return "https://text.pollinations.ai/openai/chat/completions";
|
||||
}
|
||||
|
||||
buildHeaders(credentials: any, stream = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// API key is OPTIONAL — skip Authorization header if no key provided
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
|
||||
// Pollinations uses model names directly like "openai", "claude", "deepseek", etc.
|
||||
// No transformation needed — the model name is already the Pollinations alias.
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export default PollinationsExecutor;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
/**
|
||||
* PuterExecutor — OpenAI-compatible proxy for Puter AI.
|
||||
*
|
||||
* Puter exposes 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen, Mistral...)
|
||||
* through a single OpenAI-compatible REST endpoint.
|
||||
*
|
||||
* Endpoint: https://api.puter.com/puterai/openai/v1/chat/completions
|
||||
* Auth: Bearer <puter_auth_token> (from puter.com/dashboard → Copy Auth Token)
|
||||
* Docs: https://docs.puter.com/AI/
|
||||
*
|
||||
* Model ID examples:
|
||||
* OpenAI: "gpt-4o-mini", "gpt-4o", "gpt-4.1"
|
||||
* Claude: "claude-sonnet-4-5", "claude-opus-4", "claude-haiku-4-5"
|
||||
* Gemini: "google/gemini-2.0-flash", "google/gemini-2.5-pro"
|
||||
* DeepSeek: "deepseek/deepseek-chat", "deepseek/deepseek-r1"
|
||||
* Grok: "x-ai/grok-3", "x-ai/grok-4"
|
||||
* Mistral: "mistralai/mistral-small-3.2"
|
||||
* Meta: "meta-llama/llama-3.3-70b-instruct"
|
||||
*
|
||||
* Note: Image generation, TTS, STT, and video are puter.js SDK-only features.
|
||||
* Only text chat completions (with streaming SSE) are available via REST.
|
||||
*/
|
||||
export class PuterExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("puter", PROVIDERS["puter"] || { format: "openai" });
|
||||
}
|
||||
|
||||
buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials = null): string {
|
||||
return "https://api.puter.com/puterai/openai/v1/chat/completions";
|
||||
}
|
||||
|
||||
buildHeaders(credentials: any, stream = true): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
if (key) {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
|
||||
// Puter accepts model IDs directly from its catalog.
|
||||
// No transformation required — model string is passed as-is.
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export default PuterExecutor;
|
||||
@@ -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") {
|
||||
|
||||
@@ -308,6 +308,27 @@ export async function handleChatCore({
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// (#527) tool_result → convert to text instead of dropping.
|
||||
// When Claude Code + superpowers routes through Codex, it sends tool_result
|
||||
// blocks in user messages. Silently dropping them causes Codex to loop
|
||||
// because it never receives the tool response and keeps re-requesting it.
|
||||
if (block.type === "tool_result") {
|
||||
const toolId = block.tool_use_id ?? block.id ?? "unknown";
|
||||
const resultContent = block.content ?? block.text ?? block.output ?? "";
|
||||
const resultText =
|
||||
typeof resultContent === "string"
|
||||
? resultContent
|
||||
: Array.isArray(resultContent)
|
||||
? resultContent
|
||||
.filter((c: Record<string, unknown>) => c.type === "text")
|
||||
.map((c: Record<string, unknown>) => c.text)
|
||||
.join("\n")
|
||||
: JSON.stringify(resultContent);
|
||||
if (resultText.length > 0) {
|
||||
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
// Unknown types: drop silently
|
||||
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
|
||||
return [];
|
||||
@@ -317,10 +338,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,
|
||||
|
||||
@@ -8,6 +8,46 @@ import {
|
||||
} from "../config/constants.ts";
|
||||
import { getProviderCategory } from "../config/providerRegistry.ts";
|
||||
|
||||
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
|
||||
// When a 401 body contains these strings, the account is permanently dead
|
||||
// and should NOT be retried after token refresh.
|
||||
export const ACCOUNT_DEACTIVATED_SIGNALS = [
|
||||
"account_deactivated",
|
||||
"account has been deactivated",
|
||||
"account has been disabled",
|
||||
"your account has been suspended",
|
||||
"this account is deactivated",
|
||||
];
|
||||
|
||||
// T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted.
|
||||
// Distinct from rate-limit 429 — the account won't recover until credits are added.
|
||||
export const CREDITS_EXHAUSTED_SIGNALS = [
|
||||
"insufficient_quota",
|
||||
"billing_hard_limit_reached",
|
||||
"exceeded your current quota",
|
||||
"credit_balance_too_low",
|
||||
"your credit balance is too low",
|
||||
"credits exhausted",
|
||||
"out of credits",
|
||||
"payment required",
|
||||
];
|
||||
|
||||
/**
|
||||
* T06: Returns true if response body indicates the account is permanently deactivated.
|
||||
*/
|
||||
export function isAccountDeactivated(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
* T10: Returns true if response body indicates credits/quota are permanently exhausted.
|
||||
*/
|
||||
export function isCreditsExhausted(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
// ─── Provider Profile Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -201,6 +241,14 @@ export function classifyErrorText(errorText) {
|
||||
) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
// T10: credits_exhausted signals
|
||||
if (isCreditsExhausted(errorText)) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
// T06: account_deactivated signals
|
||||
if (isAccountDeactivated(errorText)) {
|
||||
return RateLimitReason.AUTH_ERROR;
|
||||
}
|
||||
if (
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests") ||
|
||||
@@ -301,6 +349,26 @@ export function checkFallbackError(
|
||||
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
|
||||
const lowerError = errorStr.toLowerCase();
|
||||
|
||||
// T06 (sub2api #1037): Permanent account deactivation — do NOT retry, mark as permanent failure
|
||||
if (isAccountDeactivated(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: 365 * 24 * 60 * 60 * 1000, // 1 year = effectively permanent
|
||||
reason: RateLimitReason.AUTH_ERROR,
|
||||
permanent: true,
|
||||
};
|
||||
}
|
||||
|
||||
// T10 (sub2api #1169): Credits/quota exhausted — long cooldown, distinct from rate limit
|
||||
if (isCreditsExhausted(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown
|
||||
reason: RateLimitReason.QUOTA_EXHAUSTED,
|
||||
creditsExhausted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes("no credentials")) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
|
||||
@@ -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;
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,7 +34,11 @@ interface Message {
|
||||
|
||||
// ── Context Caching Tag ─────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_TAG_PATTERN = /<omniModel>([^<]+)<\/omniModel>/;
|
||||
// Handles both actual newlines (U+000A) and literal \n sequences injected
|
||||
// by combo.ts streaming around the <omniModel> tag (#531). Non-global so that
|
||||
// .exec() and .test() stay stateless; callers that need full replacement use
|
||||
// String.prototype.replace() which replaces all non-overlapping matches.
|
||||
const CACHE_TAG_PATTERN = /(?:\\n|\n)?<omniModel>([^<]+)<\/omniModel>(?:\\n|\n)?/;
|
||||
|
||||
/**
|
||||
* Inject the model tag into the last assistant message (or append a new one).
|
||||
@@ -165,7 +169,11 @@ export function applyComboAgentMiddleware(
|
||||
if (comboConfig.context_cache_protection) {
|
||||
pinnedModel = extractPinnedModel(messages);
|
||||
if (pinnedModel) {
|
||||
// Model is pinned — caller should override model selection
|
||||
// (#535) Model is pinned via <omniModel> tag — override body.model so the combo
|
||||
// router uses exactly this model instead of picking a different one. Without this,
|
||||
// the extracted pinnedModel is returned but body.model is unchanged, breaking
|
||||
// context cache sessions by sending subsequent turns to a different model.
|
||||
body = { ...body, model: pinnedModel };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,95 @@ export function getActiveSessions(): Array<SessionEntry & { sessionId: string; a
|
||||
*/
|
||||
export function clearSessions(): void {
|
||||
sessions.clear();
|
||||
activeSessionsByKey.clear();
|
||||
}
|
||||
|
||||
// ─── T08: Per-API-Key Session Limit ─────────────────────────────────────────
|
||||
// Tracks concurrent sticky sessions per API key and enforces max_sessions limits.
|
||||
// Ref: sub2api PR #634 (fix: stabilize session hash + add user-level session limit)
|
||||
|
||||
// Map: apiKeyId → Set<sessionId>
|
||||
const activeSessionsByKey = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* T08: Get the number of currently active sessions for an API key.
|
||||
* @param apiKeyId - The API key's UUID from the database
|
||||
*/
|
||||
export function getActiveSessionCountForKey(apiKeyId: string): number {
|
||||
return activeSessionsByKey.get(apiKeyId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Register a session as belonging to an API key.
|
||||
* Call this after session creation is allowed (i.e., limit check passed).
|
||||
*/
|
||||
export function registerKeySession(apiKeyId: string, sessionId: string): void {
|
||||
if (!activeSessionsByKey.has(apiKeyId)) {
|
||||
activeSessionsByKey.set(apiKeyId, new Set());
|
||||
}
|
||||
activeSessionsByKey.get(apiKeyId)!.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Unregister a session from an API key's active set.
|
||||
* Call this when the request closes or the session TTL expires.
|
||||
*/
|
||||
export function unregisterKeySession(apiKeyId: string, sessionId: string): void {
|
||||
activeSessionsByKey.get(apiKeyId)?.delete(sessionId);
|
||||
// Clean up empty sets to avoid memory leaks
|
||||
if (activeSessionsByKey.get(apiKeyId)?.size === 0) {
|
||||
activeSessionsByKey.delete(apiKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Check whether adding a new session would exceed the key's max_sessions limit.
|
||||
* Returns null if allowed, or an error object to return as a 429 response.
|
||||
*
|
||||
* @param apiKeyId - The API key's UUID
|
||||
* @param maxSessions - The limit from the DB (0 = unlimited)
|
||||
*/
|
||||
export function checkSessionLimit(
|
||||
apiKeyId: string,
|
||||
maxSessions: number
|
||||
): { code: "SESSION_LIMIT_EXCEEDED"; message: string; limit: number; current: number } | null {
|
||||
if (!maxSessions || maxSessions <= 0) return null; // unlimited
|
||||
const current = getActiveSessionCountForKey(apiKeyId);
|
||||
if (current < maxSessions) return null;
|
||||
return {
|
||||
code: "SESSION_LIMIT_EXCEEDED",
|
||||
message:
|
||||
`You have reached the maximum number of active sessions (${maxSessions}). ` +
|
||||
`Please close unused sessions or wait for them to expire.`,
|
||||
limit: maxSessions,
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* T04: Extract an external session ID from request headers.
|
||||
* Accepts both hyphenated and underscore forms for Nginx compatibility.
|
||||
* Nginx drops headers with underscores by default — use `underscores_in_headers on`
|
||||
* in nginx.conf, or use X-Session-Id (hyphenated) which passes cleanly.
|
||||
*
|
||||
* Ref: sub2api README + PR #634
|
||||
*
|
||||
* @param headers - Request headers (Headers object or plain object with .get())
|
||||
* @returns External session ID with "ext:" prefix, or null
|
||||
*/
|
||||
export function extractExternalSessionId(
|
||||
headers: Headers | { get?: (n: string) => string | null } | null | undefined
|
||||
): string | null {
|
||||
if (!headers || typeof (headers as Headers).get !== "function") return null;
|
||||
const h = headers as Headers;
|
||||
const raw =
|
||||
h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx)
|
||||
h.get("x-omniroute-session") ?? // OmniRoute-specific form
|
||||
h.get("session-id") ?? // Bare session-id
|
||||
null;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
// Prefix "ext:" to ensure no collision with internal SHA-256 hash IDs
|
||||
return `ext:${raw.trim().slice(0, 64)}`; // max 64 chars to avoid abuse
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,8 @@ export const EFFORT_BUDGETS = {
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
max: 131072, // T11: Claude "max" / "xhigh" — full budget
|
||||
xhigh: 131072, // T11: explicit alias used internally
|
||||
};
|
||||
|
||||
// thinkingLevel string → budget token mapping
|
||||
@@ -28,6 +30,8 @@ export const THINKING_LEVEL_MAP = {
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
max: 131072, // T11: max = full Claude budget (sub2api: xhigh)
|
||||
xhigh: 131072, // T11: explicit xhigh alias
|
||||
};
|
||||
|
||||
// Default config (passthrough = backward compatible)
|
||||
@@ -198,7 +202,7 @@ function setCustomBudget(body, budget) {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI reasoning_effort mapping
|
||||
// OpenAI reasoning_effort mapping (T11: add 'max' tier for full budget)
|
||||
if (result.reasoning_effort !== undefined || result.reasoning !== undefined) {
|
||||
if (budget <= 0) {
|
||||
delete result.reasoning_effort;
|
||||
@@ -207,8 +211,10 @@ function setCustomBudget(body, budget) {
|
||||
result.reasoning_effort = "low";
|
||||
} else if (budget <= 10240) {
|
||||
result.reasoning_effort = "medium";
|
||||
} else {
|
||||
} else if (budget < 131072) {
|
||||
result.reasoning_effort = "high";
|
||||
} else {
|
||||
result.reasoning_effort = "max"; // T11: full budget → "max"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,10 +93,11 @@ export function convertResponsesApiFormat(body) {
|
||||
}
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
// Note: prompt_cache_key is intentionally preserved — it is used by Codex and other
|
||||
// providers as a cache-affinity signal. Stripping it breaks prompt caching (#517).
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
delete result.include;
|
||||
delete result.prompt_cache_key;
|
||||
delete result.store;
|
||||
delete result.reasoning;
|
||||
|
||||
|
||||
@@ -227,10 +227,11 @@ export function openaiResponsesToOpenAIRequest(
|
||||
});
|
||||
|
||||
// Cleanup Responses API specific fields
|
||||
// Note: prompt_cache_key is intentionally preserved — it is used by Codex and other
|
||||
// providers as a cache-affinity signal. Stripping it breaks prompt caching (#517).
|
||||
delete result.input;
|
||||
delete result.instructions;
|
||||
delete result.include;
|
||||
delete result.prompt_cache_key;
|
||||
delete result.store;
|
||||
delete result.reasoning;
|
||||
|
||||
|
||||
@@ -27,6 +27,60 @@ type ClaudeTool = {
|
||||
defer_loading?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* T02: Recursively strips empty text blocks from content arrays.
|
||||
* Anthropic returns 400 "text content blocks must be non-empty" if any
|
||||
* text block has text: "". Must also recurse into nested tool_result.content.
|
||||
* Ref: sub2api PR #1212
|
||||
*/
|
||||
export function stripEmptyTextBlocks(content: unknown[] | undefined): unknown[] {
|
||||
if (!Array.isArray(content)) return content ?? [];
|
||||
return content
|
||||
.filter((block: unknown) => {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as Record<string, unknown>).type === "text"
|
||||
) {
|
||||
const text = (block as Record<string, unknown>).text;
|
||||
if (text === "" || text == null) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((block: unknown) => {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
(block as Record<string, unknown>).type === "tool_result" &&
|
||||
Array.isArray((block as Record<string, unknown>).content)
|
||||
) {
|
||||
// Recurse into nested tool_result.content
|
||||
return {
|
||||
...(block as Record<string, unknown>),
|
||||
content: stripEmptyTextBlocks((block as Record<string, unknown>).content as unknown[]),
|
||||
};
|
||||
}
|
||||
return block;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* T15: Normalize content to string form.
|
||||
* Handles both string and array-of-blocks forms (Cursor, Codex 2.x, etc.).
|
||||
* Ref: sub2api PR #1197
|
||||
*/
|
||||
export function normalizeContentToString(content: string | unknown[] | null | undefined): string {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return (content as Array<Record<string, unknown>>)
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => String(b.text ?? ""))
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Convert OpenAI request to Claude format
|
||||
export function openaiToClaudeRequest(model, body, stream) {
|
||||
// Check if tool prefix should be disabled (configured per-provider or global)
|
||||
@@ -61,11 +115,11 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
const systemParts = [];
|
||||
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
// Extract system messages
|
||||
// Extract system messages (T15: handle both string and array content)
|
||||
for (const msg of body.messages) {
|
||||
if (msg.role === "system") {
|
||||
systemParts.push(
|
||||
typeof msg.content === "string" ? msg.content : extractTextContent(msg.content)
|
||||
typeof msg.content === "string" ? msg.content : normalizeContentToString(msg.content)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -270,10 +324,14 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
|
||||
const blocks = [];
|
||||
|
||||
if (msg.role === "tool") {
|
||||
// T02: Strip empty text blocks from nested tool_result content to avoid Anthropic 400
|
||||
const toolContent = Array.isArray(msg.content)
|
||||
? stripEmptyTextBlocks(msg.content)
|
||||
: msg.content;
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: msg.tool_call_id,
|
||||
content: msg.content,
|
||||
content: toolContent,
|
||||
});
|
||||
} else if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
@@ -287,10 +345,14 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
|
||||
} else if (part.type === "tool_result") {
|
||||
// Skip tool_result with no tool_use_id (would be useless and may cause errors)
|
||||
if (!part.tool_use_id) continue;
|
||||
// T02: strip empty text blocks from nested content before passing to Anthropic
|
||||
const resultContent = Array.isArray(part.content)
|
||||
? stripEmptyTextBlocks(part.content)
|
||||
: part.content;
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.tool_use_id,
|
||||
content: part.content,
|
||||
content: resultContent,
|
||||
...(part.is_error && { is_error: part.is_error }),
|
||||
});
|
||||
} else if (part.type === "image_url") {
|
||||
|
||||
Generated
+7586
-89
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.8.9",
|
||||
"version": "3.0.0-rc.9",
|
||||
"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": {
|
||||
@@ -81,8 +81,10 @@
|
||||
"system-info": "node scripts/system-info.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobehub/icons": "^5.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@swc/helpers": "0.5.19",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bottleneck": "^2.19.5",
|
||||
@@ -94,7 +96,7 @@
|
||||
"jose": "^6.1.3",
|
||||
"lowdb": "^7.0.1",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.0.10",
|
||||
"next-intl": "^4.8.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"open": "^11.0.0",
|
||||
@@ -110,8 +112,7 @@
|
||||
"uuid": "^13.0.0",
|
||||
"wreq-js": "^2.0.1",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10",
|
||||
"@swc/helpers": "0.5.19"
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
@@ -124,7 +125,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "^16.0.10",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Scaleway</title><path d="M16.605 11.11v5.72a1.77 1.77 0 01-1.54 1.69h-4a1.43 1.43 0 01-1.31-1.22 1.09 1.09 0 010-.18 1.37 1.37 0 011.37-1.36h1.74a1 1 0 001-1v-3.62a1.4 1.4 0 011.18-1.39h.17a1.37 1.37 0 011.39 1.36zm-6.46 1.74V9.26a1 1 0 011-1h1.85a1.37 1.37 0 001.37-1.37 1 1 0 000-.17 1.45 1.45 0 00-1.41-1.2h-3.96a1.81 1.81 0 00-1.58 1.66v5.7a1.37 1.37 0 001.37 1.37h.21a1.4 1.4 0 001.15-1.4zm12-4.29V20a4.53 4.53 0 01-4.15 4h-7.58a8.57 8.57 0 01-8.56-8.57V4.54A4.54 4.54 0 016.395 0h7.18a8.56 8.56 0 018.56 8.56zm-2.74 0a5.83 5.83 0 00-5.82-5.82h-7.19a1.79 1.79 0 00-1.8 1.8v10.89a5.83 5.83 0 005.82 5.8h7.44a1.79 1.79 0 001.54-1.48z"/></svg>
|
||||
|
After Width: | Height: | Size: 723 B |
@@ -48,7 +48,8 @@ function extractChangelogSections(content) {
|
||||
}
|
||||
|
||||
function isSemver(value) {
|
||||
return /^\d+\.\d+\.\d+$/.test(value);
|
||||
// Accept X.Y.Z and X.Y.Z-prerelease.N (e.g. 3.0.0-rc.1, 3.0.0-beta.2)
|
||||
return /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(value);
|
||||
}
|
||||
|
||||
let hasFailure = false;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -523,15 +523,12 @@ export default function ApiManagerPageClient() {
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-1.5">
|
||||
<code className="text-sm text-text-muted font-mono truncate">{key.key}</code>
|
||||
<button
|
||||
onClick={() => copy(key.key, key.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all shrink-0"
|
||||
title={t("copyMaskedKey")}
|
||||
<span
|
||||
className="p-1 text-text-muted/40 opacity-0 group-hover:opacity-100 transition-all shrink-0 cursor-help"
|
||||
title={t("keyOnlyAvailableAtCreation")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{copied === key.id ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[14px]">lock</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
|
||||
@@ -58,8 +58,10 @@ export default function ClaudeToolCard({
|
||||
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
|
||||
|
||||
useEffect(() => {
|
||||
// (#523) Store the key *id* (not the masked string) so the backend can
|
||||
// resolve the real secret from DB before writing to settings.json.
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
setSelectedApiKey(apiKeys[0].id);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
@@ -95,10 +97,11 @@ export default function ClaudeToolCard({
|
||||
}
|
||||
}
|
||||
});
|
||||
// Only set selectedApiKey if it exists in apiKeys list
|
||||
// Restore selected key from file: match token stored in file against known keys
|
||||
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (tokenFromFile && apiKeys?.some((k) => k.key === tokenFromFile)) {
|
||||
setSelectedApiKey(tokenFromFile);
|
||||
if (tokenFromFile) {
|
||||
const matchedKey = apiKeys?.find((k) => k.key === tokenFromFile);
|
||||
if (matchedKey) setSelectedApiKey(matchedKey.id);
|
||||
}
|
||||
}
|
||||
}, [claudeStatus, apiKeys, tool.defaultModels, onModelMappingChange]);
|
||||
@@ -132,24 +135,27 @@ export default function ClaudeToolCard({
|
||||
try {
|
||||
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
|
||||
|
||||
// Get key from dropdown, fallback to first key or sk_omniroute for localhost
|
||||
const keyToUse =
|
||||
selectedApiKey?.trim() ||
|
||||
(apiKeys?.length > 0 ? apiKeys[0].key : null) ||
|
||||
(!cloudEnabled ? "sk_omniroute" : null);
|
||||
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
|
||||
// Fall back to sk_omniroute for localhost-only setups without a key.
|
||||
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
|
||||
const skOmnirouteFallback = !cloudEnabled ? "sk_omniroute" : null;
|
||||
|
||||
if (keyToUse) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
|
||||
if (!selectedKeyId && skOmnirouteFallback) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = skOmnirouteFallback;
|
||||
}
|
||||
|
||||
tool.defaultModels.forEach((model) => {
|
||||
const targetModel = modelMappings[model.alias];
|
||||
if (targetModel && model.envKey) env[model.envKey] = targetModel;
|
||||
});
|
||||
|
||||
const postBody: Record<string, unknown> = { env };
|
||||
if (selectedKeyId) postBody.keyId = selectedKeyId;
|
||||
|
||||
const res = await fetch("/api/cli-tools/claude-settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
body: JSON.stringify(postBody),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
@@ -412,7 +418,7 @@ export default function ClaudeToolCard({
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => (
|
||||
<option key={key.id} value={key.key}>
|
||||
<option key={key.id} value={key.id}>
|
||||
{key.key}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import PropTypes from "prop-types";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
@@ -31,6 +31,129 @@ import {
|
||||
} from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import {
|
||||
MODEL_COMPAT_PROTOCOL_KEYS,
|
||||
type ModelCompatProtocolKey,
|
||||
} from "@/shared/constants/modelCompat";
|
||||
|
||||
type CompatByProtocolMap = Partial<
|
||||
Record<
|
||||
ModelCompatProtocolKey,
|
||||
{ normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean }
|
||||
>
|
||||
>;
|
||||
type CompatModelRow = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
apiFormat?: string;
|
||||
supportedEndpoints?: string[];
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getProtoSlice(
|
||||
c: CompatModelRow | undefined,
|
||||
o: CompatModelRow | undefined,
|
||||
protocol: string
|
||||
) {
|
||||
return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol];
|
||||
}
|
||||
|
||||
function effectiveNormalizeForProtocol(
|
||||
modelId: string,
|
||||
protocol: string,
|
||||
customMap: CompatModelMap,
|
||||
overrideMap: CompatModelMap
|
||||
): boolean {
|
||||
const c = customMap.get(modelId);
|
||||
const o = overrideMap.get(modelId);
|
||||
const pc = getProtoSlice(c, o, protocol);
|
||||
if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) {
|
||||
return Boolean(pc.normalizeToolCallId);
|
||||
}
|
||||
if (c?.normalizeToolCallId) return true;
|
||||
return Boolean(o?.normalizeToolCallId);
|
||||
}
|
||||
|
||||
function effectivePreserveForProtocol(
|
||||
modelId: string,
|
||||
protocol: string,
|
||||
customMap: CompatModelMap,
|
||||
overrideMap: CompatModelMap
|
||||
): boolean {
|
||||
const c = customMap.get(modelId);
|
||||
const o = overrideMap.get(modelId);
|
||||
const pc = getProtoSlice(c, o, protocol);
|
||||
if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(pc.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
if (c && Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(c.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
if (o && Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(o.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function anyNormalizeCompatBadge(
|
||||
modelId: string,
|
||||
customMap: CompatModelMap,
|
||||
overrideMap: CompatModelMap
|
||||
): boolean {
|
||||
const c = customMap.get(modelId);
|
||||
const o = overrideMap.get(modelId);
|
||||
if (c?.normalizeToolCallId || o?.normalizeToolCallId) return true;
|
||||
for (const p of MODEL_COMPAT_PROTOCOL_KEYS) {
|
||||
const pc = getProtoSlice(c, o, p);
|
||||
if (pc?.normalizeToolCallId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function anyNoPreserveCompatBadge(
|
||||
modelId: string,
|
||||
customMap: CompatModelMap,
|
||||
overrideMap: CompatModelMap
|
||||
): boolean {
|
||||
const c = customMap.get(modelId);
|
||||
const o = overrideMap.get(modelId);
|
||||
if (
|
||||
c &&
|
||||
Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") &&
|
||||
c.preserveOpenAIDeveloperRole === false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
o &&
|
||||
Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole") &&
|
||||
o.preserveOpenAIDeveloperRole === false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
for (const p of MODEL_COMPAT_PROTOCOL_KEYS) {
|
||||
const pc = getProtoSlice(c, o, p);
|
||||
if (
|
||||
pc &&
|
||||
Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole") &&
|
||||
pc.preserveOpenAIDeveloperRole === false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface ModelRowProps {
|
||||
model: { id: string };
|
||||
@@ -40,10 +163,16 @@ interface ModelRowProps {
|
||||
onCopy: (text: string, key: string) => void;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
showDeveloperToggle?: boolean;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
onNormalizeChange?: (v: boolean) => void;
|
||||
onPreserveChange?: (v: boolean) => void;
|
||||
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
|
||||
saveModelCompatFlags: (
|
||||
modelId: string,
|
||||
patch: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
}
|
||||
) => void;
|
||||
compatDisabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -55,10 +184,16 @@ interface PassthroughModelRowProps {
|
||||
onDeleteAlias: () => void;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
showDeveloperToggle?: boolean;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
onNormalizeChange?: (v: boolean) => void;
|
||||
onPreserveChange?: (v: boolean) => void;
|
||||
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
|
||||
saveModelCompatFlags: (
|
||||
modelId: string,
|
||||
patch: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
}
|
||||
) => void;
|
||||
compatDisabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -230,27 +365,43 @@ function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly
|
||||
};
|
||||
}
|
||||
|
||||
function compatProtocolLabelKey(protocol: string): string {
|
||||
if (protocol === "openai") return "compatProtocolOpenAI";
|
||||
if (protocol === "openai-responses") return "compatProtocolOpenAIResponses";
|
||||
if (protocol === "claude") return "compatProtocolClaude";
|
||||
return "compatProtocolOpenAI";
|
||||
}
|
||||
|
||||
function ModelCompatPopover({
|
||||
t,
|
||||
normalizeToolCallId,
|
||||
preserveDeveloperRole,
|
||||
effectiveModelNormalize,
|
||||
effectiveModelPreserveDeveloper,
|
||||
onCompatPatch,
|
||||
showDeveloperToggle = true,
|
||||
onNormalizeChange,
|
||||
onPreserveChange,
|
||||
disabled,
|
||||
}: {
|
||||
t: (key: string) => string;
|
||||
normalizeToolCallId: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
effectiveModelNormalize: (protocol: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (protocol: string) => boolean;
|
||||
onCompatPatch: (
|
||||
protocol: string,
|
||||
payload: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
}
|
||||
) => void;
|
||||
showDeveloperToggle?: boolean;
|
||||
onNormalizeChange: (v: boolean) => void;
|
||||
onPreserveChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [protocol, setProtocol] = useState<string>(MODEL_COMPAT_PROTOCOL_KEYS[0]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const normalizeToolCallId = effectiveModelNormalize(protocol);
|
||||
const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol);
|
||||
const devToggle = showDeveloperToggle && protocol !== "claude";
|
||||
|
||||
// Click-outside: check both trigger and panel so that if the panel is ever rendered
|
||||
// in a portal (outside this subtree), clicks inside the panel still do not close it.
|
||||
useEffect(() => {
|
||||
@@ -280,32 +431,47 @@ function ModelCompatPopover({
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10"
|
||||
className="absolute left-0 top-full mt-1 z-50 min-w-[220px] max-w-[92vw] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10"
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-1">
|
||||
{t("compatAdjustmentsTitle")}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted mb-2 leading-snug">{t("compatProtocolHint")}</p>
|
||||
<label className="block text-[10px] font-medium text-text-muted mb-1">
|
||||
{t("compatProtocolLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full mb-3 px-2 py-1.5 text-xs rounded-md border border-border bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{MODEL_COMPAT_PROTOCOL_KEYS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(compatProtocolLabelKey(p))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Toggle
|
||||
size="sm"
|
||||
label={t("compatToolIdShort")}
|
||||
title={t("normalizeToolCallIdLabel")}
|
||||
checked={normalizeToolCallId}
|
||||
onChange={onNormalizeChange}
|
||||
onChange={(v) => onCompatPatch(protocol, { normalizeToolCallId: v })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showDeveloperToggle && (
|
||||
<>
|
||||
{/* Inversion: Toggle checked = "do not preserve" (UI). onPreserveChange(val) = value to store (true = preserve, false = do not preserve), so we pass !checked. */}
|
||||
<Toggle
|
||||
size="sm"
|
||||
label={t("compatDoNotPreserveDeveloper")}
|
||||
title={t("preserveDeveloperRoleLabel")}
|
||||
checked={preserveDeveloperRole === false}
|
||||
onChange={(checked) => onPreserveChange(!checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
{devToggle && (
|
||||
<Toggle
|
||||
size="sm"
|
||||
label={t("compatDoNotPreserveDeveloper")}
|
||||
title={t("preserveDeveloperRoleLabel")}
|
||||
checked={preserveDeveloperRole === false}
|
||||
onChange={(checked) =>
|
||||
onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,12 +516,8 @@ export default function ProviderDetailPage() {
|
||||
importedCount: 0,
|
||||
});
|
||||
const [modelMeta, setModelMeta] = useState<{
|
||||
customModels: Record<string, unknown>[];
|
||||
modelCompatOverrides: {
|
||||
id: string;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
}[];
|
||||
customModels: CompatModelRow[];
|
||||
modelCompatOverrides: Array<CompatModelRow & { id: string }>;
|
||||
}>({ customModels: [], modelCompatOverrides: [] });
|
||||
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
|
||||
|
||||
@@ -1020,48 +1182,91 @@ export default function ProviderDetailPage() {
|
||||
|
||||
const canImportModels = connections.some((conn) => conn.isActive !== false);
|
||||
|
||||
const effectiveModelNormalize = (modelId: string) => {
|
||||
const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as
|
||||
| { normalizeToolCallId?: boolean }
|
||||
| undefined;
|
||||
if (c) return Boolean(c.normalizeToolCallId);
|
||||
const o = modelMeta.modelCompatOverrides.find((e) => e.id === modelId);
|
||||
return Boolean(o?.normalizeToolCallId);
|
||||
};
|
||||
const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]);
|
||||
const overrideMap = useMemo(
|
||||
() => buildCompatMap(modelMeta.modelCompatOverrides),
|
||||
[modelMeta.modelCompatOverrides]
|
||||
);
|
||||
|
||||
const effectiveModelPreserveDeveloper = (modelId: string) => {
|
||||
const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (c && Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(c.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
const o = modelMeta.modelCompatOverrides.find((e) => e.id === modelId);
|
||||
if (o && Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(o.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const effectiveModelNormalize = (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) =>
|
||||
effectiveNormalizeForProtocol(modelId, protocol, customMap, overrideMap);
|
||||
|
||||
const effectiveModelPreserveDeveloper = (
|
||||
modelId: string,
|
||||
protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]
|
||||
) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap);
|
||||
|
||||
const saveModelCompatFlags = async (
|
||||
modelId: string,
|
||||
patch: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean }
|
||||
patch: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
}
|
||||
) => {
|
||||
setCompatSavingModelId(modelId);
|
||||
try {
|
||||
const body: Record<string, unknown> = { provider: providerId, modelId, ...patch };
|
||||
const c = customMap.get(modelId) as Record<string, unknown> | undefined;
|
||||
let body: Record<string, unknown>;
|
||||
const onlyCompatByProtocol =
|
||||
patch.compatByProtocol &&
|
||||
patch.normalizeToolCallId === undefined &&
|
||||
patch.preserveOpenAIDeveloperRole === undefined;
|
||||
|
||||
if (c) {
|
||||
if (onlyCompatByProtocol) {
|
||||
body = {
|
||||
provider: providerId,
|
||||
modelId,
|
||||
compatByProtocol: patch.compatByProtocol,
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
provider: providerId,
|
||||
modelId,
|
||||
modelName: (c.name as string) || modelId,
|
||||
source: (c.source as string) || "manual",
|
||||
apiFormat: (c.apiFormat as string) || "chat-completions",
|
||||
supportedEndpoints:
|
||||
Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length
|
||||
? c.supportedEndpoints
|
||||
: ["chat"],
|
||||
normalizeToolCallId:
|
||||
patch.normalizeToolCallId !== undefined
|
||||
? patch.normalizeToolCallId
|
||||
: Boolean(c.normalizeToolCallId),
|
||||
preserveOpenAIDeveloperRole:
|
||||
patch.preserveOpenAIDeveloperRole !== undefined
|
||||
? patch.preserveOpenAIDeveloperRole
|
||||
: Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")
|
||||
? Boolean(c.preserveOpenAIDeveloperRole)
|
||||
: true,
|
||||
};
|
||||
if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol;
|
||||
}
|
||||
} else {
|
||||
body = { provider: providerId, modelId, ...patch };
|
||||
}
|
||||
const res = await fetch("/api/provider-models", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) await fetchProviderModelMeta();
|
||||
else notify.error(t("failedSaveCustomModel"));
|
||||
if (!res.ok) {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
return;
|
||||
} finally {
|
||||
setCompatSavingModelId(null);
|
||||
}
|
||||
try {
|
||||
await fetchProviderModelMeta();
|
||||
} catch {
|
||||
/* refresh failure is non-critical — data was already saved */
|
||||
}
|
||||
};
|
||||
|
||||
const renderModelsSection = () => {
|
||||
@@ -1166,14 +1371,9 @@ export default function ProviderDetailPage() {
|
||||
onCopy={copy}
|
||||
t={t}
|
||||
showDeveloperToggle
|
||||
normalizeToolCallId={effectiveModelNormalize(model.id)}
|
||||
preserveDeveloperRole={effectiveModelPreserveDeveloper(model.id)}
|
||||
onNormalizeChange={(v) =>
|
||||
saveModelCompatFlags(model.id, { normalizeToolCallId: v })
|
||||
}
|
||||
onPreserveChange={(v) =>
|
||||
saveModelCompatFlags(model.id, { preserveOpenAIDeveloperRole: v })
|
||||
}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatDisabled={compatSavingModelId === model.id}
|
||||
/>
|
||||
);
|
||||
@@ -1813,10 +2013,9 @@ function ModelRow({
|
||||
onCopy,
|
||||
t,
|
||||
showDeveloperToggle = true,
|
||||
normalizeToolCallId,
|
||||
preserveDeveloperRole,
|
||||
onNormalizeChange,
|
||||
onPreserveChange,
|
||||
effectiveModelNormalize,
|
||||
effectiveModelPreserveDeveloper,
|
||||
saveModelCompatFlags,
|
||||
compatDisabled,
|
||||
}: ModelRowProps) {
|
||||
return (
|
||||
@@ -1840,11 +2039,12 @@ function ModelRow({
|
||||
</div>
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
normalizeToolCallId={Boolean(normalizeToolCallId)}
|
||||
preserveDeveloperRole={preserveDeveloperRole}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
onNormalizeChange={onNormalizeChange}
|
||||
onPreserveChange={onPreserveChange}
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -1861,10 +2061,9 @@ ModelRow.propTypes = {
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
t: PropTypes.func,
|
||||
showDeveloperToggle: PropTypes.bool,
|
||||
normalizeToolCallId: PropTypes.bool,
|
||||
preserveDeveloperRole: PropTypes.bool,
|
||||
onNormalizeChange: PropTypes.func,
|
||||
onPreserveChange: PropTypes.func,
|
||||
effectiveModelNormalize: PropTypes.func.isRequired,
|
||||
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
|
||||
saveModelCompatFlags: PropTypes.func.isRequired,
|
||||
compatDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -1960,12 +2159,9 @@ function PassthroughModelsSection({
|
||||
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||
t={t}
|
||||
showDeveloperToggle
|
||||
normalizeToolCallId={effectiveModelNormalize(modelId)}
|
||||
preserveDeveloperRole={effectiveModelPreserveDeveloper(modelId)}
|
||||
onNormalizeChange={(v) => saveModelCompatFlags(modelId, { normalizeToolCallId: v })}
|
||||
onPreserveChange={(v) =>
|
||||
saveModelCompatFlags(modelId, { preserveOpenAIDeveloperRole: v })
|
||||
}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatDisabled={compatSavingModelId === modelId}
|
||||
/>
|
||||
))}
|
||||
@@ -1997,10 +2193,9 @@ function PassthroughModelRow({
|
||||
onDeleteAlias,
|
||||
t,
|
||||
showDeveloperToggle = true,
|
||||
normalizeToolCallId,
|
||||
preserveDeveloperRole,
|
||||
onNormalizeChange,
|
||||
onPreserveChange,
|
||||
effectiveModelNormalize,
|
||||
effectiveModelPreserveDeveloper,
|
||||
saveModelCompatFlags,
|
||||
compatDisabled,
|
||||
}: PassthroughModelRowProps) {
|
||||
return (
|
||||
@@ -2037,11 +2232,12 @@ function PassthroughModelRow({
|
||||
<div className="pl-9">
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
normalizeToolCallId={Boolean(normalizeToolCallId)}
|
||||
preserveDeveloperRole={preserveDeveloperRole}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
onNormalizeChange={onNormalizeChange}
|
||||
onPreserveChange={onPreserveChange}
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -2057,10 +2253,9 @@ PassthroughModelRow.propTypes = {
|
||||
onDeleteAlias: PropTypes.func.isRequired,
|
||||
t: PropTypes.func,
|
||||
showDeveloperToggle: PropTypes.bool,
|
||||
normalizeToolCallId: PropTypes.bool,
|
||||
preserveDeveloperRole: PropTypes.bool,
|
||||
onNormalizeChange: PropTypes.func,
|
||||
onPreserveChange: PropTypes.func,
|
||||
effectiveModelNormalize: PropTypes.func.isRequired,
|
||||
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
|
||||
saveModelCompatFlags: PropTypes.func.isRequired,
|
||||
compatDisabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -2075,7 +2270,10 @@ function CustomModelsSection({
|
||||
}: CustomModelsSectionProps) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [customModels, setCustomModels] = useState<CompatModelRow[]>([]);
|
||||
const [modelCompatOverrides, setModelCompatOverrides] = useState<
|
||||
Array<CompatModelRow & { id: string }>
|
||||
>([]);
|
||||
const [newModelId, setNewModelId] = useState("");
|
||||
const [newModelName, setNewModelName] = useState("");
|
||||
const [newApiFormat, setNewApiFormat] = useState("chat-completions");
|
||||
@@ -2085,16 +2283,18 @@ function CustomModelsSection({
|
||||
const [editingModelId, setEditingModelId] = useState<string | null>(null);
|
||||
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
|
||||
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
|
||||
const [editingNormalizeToolCallId, setEditingNormalizeToolCallId] = useState(false);
|
||||
const [editingPreserveDeveloperRole, setEditingPreserveDeveloperRole] = useState(false);
|
||||
const [savingModelId, setSavingModelId] = useState<string | null>(null);
|
||||
|
||||
const customMap = useMemo(() => buildCompatMap(customModels), [customModels]);
|
||||
const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]);
|
||||
|
||||
const fetchCustomModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustomModels(data.models || []);
|
||||
setModelCompatOverrides(data.modelCompatOverrides || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch custom models:", e);
|
||||
@@ -2160,23 +2360,44 @@ function CustomModelsSection({
|
||||
? model.supportedEndpoints
|
||||
: ["chat"]
|
||||
);
|
||||
setEditingNormalizeToolCallId(Boolean(model.normalizeToolCallId));
|
||||
setEditingPreserveDeveloperRole(
|
||||
Object.prototype.hasOwnProperty.call(model, "preserveOpenAIDeveloperRole")
|
||||
? Boolean(model.preserveOpenAIDeveloperRole)
|
||||
: true
|
||||
);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingModelId(null);
|
||||
setEditingApiFormat("chat-completions");
|
||||
setEditingEndpoints(["chat"]);
|
||||
setEditingNormalizeToolCallId(false);
|
||||
setEditingPreserveDeveloperRole(true);
|
||||
setSavingModelId(null);
|
||||
};
|
||||
|
||||
const saveCustomCompat = async (
|
||||
modelId: string,
|
||||
patch: { compatByProtocol?: CompatByProtocolMap }
|
||||
) => {
|
||||
setSavingModelId(modelId);
|
||||
try {
|
||||
const res = await fetch("/api/provider-models", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: providerId, modelId, ...patch }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
return;
|
||||
} finally {
|
||||
setSavingModelId(null);
|
||||
}
|
||||
try {
|
||||
await fetchCustomModels();
|
||||
onModelsChanged?.();
|
||||
} catch {
|
||||
/* refresh failure is non-critical — data was already saved */
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async (modelId) => {
|
||||
if (!editingModelId || editingModelId !== modelId) return;
|
||||
if (!editingEndpoints.length) {
|
||||
@@ -2197,8 +2418,6 @@ function CustomModelsSection({
|
||||
source: model?.source || "manual",
|
||||
apiFormat: editingApiFormat,
|
||||
supportedEndpoints: editingEndpoints,
|
||||
normalizeToolCallId: editingNormalizeToolCallId,
|
||||
preserveOpenAIDeveloperRole: editingPreserveDeveloperRole,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2361,7 +2580,7 @@ function CustomModelsSection({
|
||||
🔊 Audio
|
||||
</span>
|
||||
)}
|
||||
{model.normalizeToolCallId && (
|
||||
{anyNormalizeCompatBadge(model.id, customMap, overrideMap) && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
|
||||
title={t("normalizeToolCallIdLabel")}
|
||||
@@ -2369,7 +2588,7 @@ function CustomModelsSection({
|
||||
ID×9
|
||||
</span>
|
||||
)}
|
||||
{model.preserveOpenAIDeveloperRole === false && (
|
||||
{anyNoPreserveCompatBadge(model.id, customMap, overrideMap) && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium"
|
||||
title={t("compatDoNotPreserveDeveloper")}
|
||||
@@ -2433,11 +2652,18 @@ function CustomModelsSection({
|
||||
<div className="mt-3 pt-3 border-t border-border/80 w-full">
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
normalizeToolCallId={editingNormalizeToolCallId}
|
||||
preserveDeveloperRole={editingPreserveDeveloperRole}
|
||||
effectiveModelNormalize={(p) =>
|
||||
effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap)
|
||||
}
|
||||
effectiveModelPreserveDeveloper={(p) =>
|
||||
effectivePreserveForProtocol(model.id, p, customMap, overrideMap)
|
||||
}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveCustomCompat(model.id, {
|
||||
compatByProtocol: { [protocol]: payload },
|
||||
})
|
||||
}
|
||||
showDeveloperToggle
|
||||
onNormalizeChange={setEditingNormalizeToolCallId}
|
||||
onPreserveChange={setEditingPreserveDeveloperRole}
|
||||
disabled={savingModelId === model.id}
|
||||
/>
|
||||
</div>
|
||||
@@ -2716,12 +2942,9 @@ function CompatibleModelsSection({
|
||||
onDeleteAlias={() => handleDeleteModel(modelId, alias)}
|
||||
t={t}
|
||||
showDeveloperToggle={!isAnthropic}
|
||||
normalizeToolCallId={effectiveModelNormalize(modelId)}
|
||||
preserveDeveloperRole={effectiveModelPreserveDeveloper(modelId)}
|
||||
onNormalizeChange={(v) => saveModelCompatFlags(modelId, { normalizeToolCallId: v })}
|
||||
onPreserveChange={(v) =>
|
||||
saveModelCompatFlags(modelId, { preserveOpenAIDeveloperRole: v })
|
||||
}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatDisabled={compatSavingModelId === modelId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
Card,
|
||||
@@ -490,16 +491,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
const { connected, error, errorCode, errorTime, allDisabled } = stats;
|
||||
const [imgSrc, setImgSrc] = useState(`/providers/${provider.id}.png`);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
if (imgSrc.endsWith(".png")) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
// (#529) Icon state replaced by ProviderIcon component (Lobehub + PNG + generic fallback)
|
||||
|
||||
const dotColors = {
|
||||
free: "bg-green-500",
|
||||
@@ -526,21 +519,8 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={imgSrc}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[32px] max-h-[32px]"
|
||||
sizes="32px"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
)}
|
||||
{/* (#529) ProviderIcon: Lobehub icons → PNG fallback → generic icon */}
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold flex items-center gap-1.5">
|
||||
@@ -633,28 +613,15 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
compatible: t("compatibleLabel"),
|
||||
};
|
||||
|
||||
// Determine icon path: OpenAI Compatible providers use specialized icons
|
||||
const getIconPath = () => {
|
||||
// (#529) Icon state replaced by ProviderIcon component
|
||||
// For compatible/anthropic providers, continue using static PNGs via the icon path
|
||||
const staticIconPath = (() => {
|
||||
if (isCompatible) {
|
||||
return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png";
|
||||
}
|
||||
if (isAnthropicCompatible) {
|
||||
return "/providers/anthropic-m.png"; // Use Anthropic icon as base
|
||||
}
|
||||
return `/providers/${provider.id}.png`;
|
||||
};
|
||||
|
||||
const [imgSrc, setImgSrc] = useState<string>(() => getIconPath());
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const handleImgError = () => {
|
||||
const basePath = getIconPath();
|
||||
if (imgSrc.endsWith(".png") && !isCompatible && !isAnthropicCompatible) {
|
||||
setImgSrc(`/providers/${provider.id}.svg`);
|
||||
} else {
|
||||
setImgError(true);
|
||||
}
|
||||
};
|
||||
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
||||
return null; // ProviderIcon will handle it
|
||||
})();
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/providers/${providerId}`} className="group">
|
||||
@@ -668,20 +635,18 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
|
||||
className="size-8 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${provider.color}15` }}
|
||||
>
|
||||
{imgError ? (
|
||||
<span className="text-xs font-bold" style={{ color: provider.color }}>
|
||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
{/* (#529) ProviderIcon with static override for compatible providers */}
|
||||
{staticIconPath ? (
|
||||
<Image
|
||||
src={imgSrc || getIconPath()}
|
||||
src={staticIconPath}
|
||||
alt={provider.name}
|
||||
width={30}
|
||||
height={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
sizes="30px"
|
||||
onError={handleImgError}
|
||||
/>
|
||||
) : (
|
||||
<ProviderIcon providerId={provider.id} size={28} type="color" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliSettingsEnvSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
// Get claude settings path based on OS
|
||||
const getClaudeSettingsPath = () => getCliPrimaryConfigPath("claude");
|
||||
@@ -100,6 +101,22 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const { env } = validation.data;
|
||||
|
||||
// (#523/#526) If a keyId was provided, resolve the real API key from DB.
|
||||
// The /api/keys list endpoint returns masked key strings — sending those to
|
||||
// disk would save an unusable half-hidden token. Resolving by ID guarantees
|
||||
// we always write the full key value to the config file.
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = keyRecord.key as string;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to whatever value was in env (e.g. sk_omniroute)
|
||||
}
|
||||
}
|
||||
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
const claudeDir = path.dirname(settingsPath);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
|
||||
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
|
||||
@@ -125,7 +126,18 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// (#526) Resolve real key from DB if keyId was provided
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });
|
||||
|
||||
@@ -39,6 +39,10 @@ export async function POST(request, { params }) {
|
||||
switch (toolId) {
|
||||
case "continue":
|
||||
return await saveContinueConfig({ baseUrl, apiKey, model });
|
||||
case "opencode":
|
||||
// (#524) OpenCode config was never saved because only 'continue' was handled here.
|
||||
// opencode reads ~/.config/opencode/config.toml — write the OmniRoute settings there.
|
||||
return await saveOpenCodeConfig({ baseUrl, apiKey, model });
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Direct config save not supported for: ${toolId}` },
|
||||
@@ -125,3 +129,56 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
configPath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OpenCode config to ~/.config/opencode/config.toml (XDG_CONFIG_HOME aware).
|
||||
* (#524) OpenCode was silently failing because this handler was missing.
|
||||
*/
|
||||
async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
|
||||
const { apiPort } = getRuntimePorts();
|
||||
// Honour $XDG_CONFIG_HOME if set, otherwise use ~/.config per the XDG Base Directory spec
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
const configPath = path.join(xdgConfigHome, "opencode", "config.toml");
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure ~/.config/opencode/ exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
// Read existing TOML to preserve any user settings outside our block
|
||||
let existingContent = "";
|
||||
try {
|
||||
existingContent = await fs.readFile(configPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh
|
||||
}
|
||||
|
||||
// Build the OmniRoute TOML block.
|
||||
// opencode config.toml uses the [provider.X] table format.
|
||||
void apiPort; // available for future port-based detection
|
||||
const omniBlock = `
|
||||
# OmniRoute managed — updated automatically by OmniRoute CLI Tools
|
||||
[provider.omniroute]
|
||||
api_key = "${apiKey || "sk_omniroute"}"
|
||||
base_url = "${normalizedBaseUrl}"
|
||||
model = "${model}"
|
||||
`;
|
||||
|
||||
// Remove old OmniRoute-managed block (if any) then append fresh one
|
||||
const cleanedContent = existingContent
|
||||
.replace(/\n?# OmniRoute managed[\s\S]*?(?=\n\[|$)/, "")
|
||||
.trimEnd();
|
||||
|
||||
const newContent = (cleanedContent ? cleanedContent + "\n" : "") + omniBlock;
|
||||
|
||||
await fs.writeFile(configPath, newContent, "utf-8");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `OpenCode config saved to ${configPath}`,
|
||||
configPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
|
||||
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
|
||||
import { cliModelConfigSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
|
||||
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
|
||||
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
|
||||
@@ -101,7 +102,18 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { baseUrl, apiKey, model } = validation.data;
|
||||
let { baseUrl, apiKey, model } = validation.data;
|
||||
|
||||
// (#526) Resolve real key from DB if keyId was provided
|
||||
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
|
||||
if (keyId) {
|
||||
try {
|
||||
const keyRecord = await getApiKeyById(keyId);
|
||||
if (keyRecord?.key) apiKey = keyRecord.key as string;
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
const openclawDir = getOpenClawDir();
|
||||
const settingsPath = getOpenClawSettingsPath();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateCustomModel,
|
||||
getModelCompatOverrides,
|
||||
mergeModelCompatOverride,
|
||||
type ModelCompatPatch,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
@@ -129,6 +130,7 @@ export async function PUT(request) {
|
||||
supportedEndpoints,
|
||||
normalizeToolCallId,
|
||||
preserveOpenAIDeveloperRole,
|
||||
compatByProtocol,
|
||||
} = validation.data;
|
||||
|
||||
const raw = rawBody as Record<string, unknown>;
|
||||
@@ -139,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);
|
||||
|
||||
@@ -147,9 +152,17 @@ 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 knownProvider =
|
||||
!!provider &&
|
||||
@@ -165,10 +178,7 @@ export async function PUT(request) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const patch: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean | null;
|
||||
} = {};
|
||||
const patch: ModelCompatPatch = {};
|
||||
if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") {
|
||||
patch.normalizeToolCallId = normalizeToolCallId;
|
||||
}
|
||||
@@ -178,6 +188,9 @@ export async function PUT(request) {
|
||||
? preserveOpenAIDeveloperRole
|
||||
: undefined;
|
||||
}
|
||||
if ("compatByProtocol" in raw && compatByProtocol && typeof compatByProtocol === "object") {
|
||||
patch.compatByProtocol = compatByProtocol;
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
mergeModelCompatOverride(provider, modelId, patch);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
import { startModelSyncScheduler } from "@/shared/services/modelSyncScheduler";
|
||||
|
||||
let syncInitialized = false;
|
||||
let modelSyncInitialized = false;
|
||||
|
||||
// POST /api/sync/initialize - Initialize cloud sync scheduler
|
||||
export async function POST(request) {
|
||||
@@ -15,9 +17,17 @@ export async function POST(request) {
|
||||
await initializeCloudSync();
|
||||
syncInitialized = true;
|
||||
|
||||
// (#488) Start model auto-sync scheduler (24h, configurable via MODEL_SYNC_INTERVAL_HOURS)
|
||||
if (!modelSyncInitialized) {
|
||||
const origin = request.headers.get("origin") || "http://localhost:20128";
|
||||
startModelSyncScheduler(origin);
|
||||
modelSyncInitialized = true;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud sync initialized successfully",
|
||||
modelSyncEnabled: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
@@ -34,6 +44,7 @@ export async function POST(request) {
|
||||
export async function GET(request) {
|
||||
return NextResponse.json({
|
||||
initialized: syncInitialized,
|
||||
modelSyncInitialized,
|
||||
message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getProviderConnectionById, updateProviderConnection, resolveProxyForConnection } from "@/lib/localDb";
|
||||
import {
|
||||
getProviderConnectionById,
|
||||
updateProviderConnection,
|
||||
resolveProxyForConnection,
|
||||
} from "@/lib/localDb";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
@@ -109,7 +113,10 @@ async function refreshAndUpdateCredentials(connection: any) {
|
||||
/**
|
||||
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ connectionId: string }> }) {
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ connectionId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { connectionId } = await params;
|
||||
|
||||
@@ -155,7 +162,34 @@ export async function GET(request: Request, { params }: { params: Promise<{ conn
|
||||
|
||||
// Populate quota cache for quota-aware account selection
|
||||
if (isRecord(usage?.quotas)) {
|
||||
setQuotaCache(connectionId, connection.provider, usage.quotas);
|
||||
setQuotaCache(
|
||||
connectionId,
|
||||
connection.provider as string,
|
||||
usage.quotas as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
// (#491) If the live usage check returned an auth error, sync the expired status
|
||||
// back to the DB so the Providers page reflects the same degraded state as
|
||||
// Limits & Quotas (which performs the live check).
|
||||
const errorMessage = typeof usage?.message === "string" ? usage.message.toLowerCase() : "";
|
||||
const isAuthError =
|
||||
errorMessage.includes("token expired") ||
|
||||
errorMessage.includes("access denied") ||
|
||||
errorMessage.includes("re-authenticate") ||
|
||||
errorMessage.includes("unauthorized");
|
||||
|
||||
if (isAuthError && connection.testStatus !== "expired") {
|
||||
try {
|
||||
await updateProviderConnection(connection.id as string, {
|
||||
testStatus: "expired",
|
||||
lastErrorType: "token_expired",
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (dbErr) {
|
||||
// Non-critical: log but don't block the response
|
||||
console.error("[Usage API] Failed to sync expired status to DB:", dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(usage);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { setAccountKeyLimit, getAccountKeyLimit } from "@/lib/db/registeredKeys";
|
||||
|
||||
const limitsSchema = z.object({
|
||||
maxActiveKeys: z.number().int().positive().nullable().optional(),
|
||||
dailyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
hourlyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/accounts/[id]/limits
|
||||
* Get the current issuance limits for an account.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const limits = getAccountKeyLimit(params.id);
|
||||
return NextResponse.json({ accountId: params.id, limits: limits ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/accounts/[id]/limits
|
||||
* Configure issuance limits for an account.
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = limitsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
setAccountKeyLimit(params.id, parsed.data);
|
||||
const updated = getAccountKeyLimit(params.id);
|
||||
return NextResponse.json({ accountId: params.id, limits: updated });
|
||||
}
|
||||
@@ -160,6 +160,7 @@ export async function POST(request) {
|
||||
// Resolve provider config — dynamic first (local override), then hardcoded
|
||||
let providerConfig: EmbeddingProvider | null =
|
||||
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
|
||||
let credentialsProviderId = provider;
|
||||
|
||||
// #496: Fallback — resolve from ALL provider_nodes (not just localhost)
|
||||
// This enables custom embedding models (e.g. google/gemini-embedding-001) whose
|
||||
@@ -180,6 +181,7 @@ export async function POST(request) {
|
||||
authHeader: "bearer",
|
||||
models: [],
|
||||
};
|
||||
credentialsProviderId = matchingNode.id || provider;
|
||||
log.info(
|
||||
"EMBED",
|
||||
`Resolved custom embedding provider: ${provider} → ${providerConfig.baseUrl}`
|
||||
@@ -200,7 +202,7 @@ export async function POST(request) {
|
||||
// Get credentials — skip for local providers (authType: "none")
|
||||
let credentials = null;
|
||||
if (providerConfig && providerConfig.authType !== "none") {
|
||||
credentials = await getProviderCredentials(provider);
|
||||
credentials = await getProviderCredentials(credentialsProviderId);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
const reportSchema = z.object({
|
||||
title: z.string().min(1).max(300),
|
||||
provider: z.string().max(80).optional(),
|
||||
accountId: z.string().max(120).optional(),
|
||||
requestId: z.string().max(200).optional(),
|
||||
errorCode: z.string().max(100).optional(),
|
||||
details: z.record(z.unknown()).optional(),
|
||||
labels: z.array(z.string().max(50)).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/issues/report
|
||||
*
|
||||
* Optionally report a quota-exceeded or key-issuance failure event to GitHub.
|
||||
*
|
||||
* Requires GITHUB_ISSUES_REPO (format: owner/repo) and GITHUB_ISSUES_TOKEN
|
||||
* environment variables to be set. If not configured, returns 202 (accepted but
|
||||
* logged only).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = reportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { title, provider, accountId, requestId, errorCode, details, labels = [] } = parsed.data;
|
||||
|
||||
const repo = process.env.GITHUB_ISSUES_REPO;
|
||||
const token = process.env.GITHUB_ISSUES_TOKEN;
|
||||
|
||||
// ── Structured body for the GitHub issue ──
|
||||
const issueBody = [
|
||||
`## ${errorCode ?? "Key Issuance Event"}`,
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"|-------|-------|",
|
||||
provider ? `| Provider | \`${provider}\` |` : null,
|
||||
accountId ? `| Account ID | \`${accountId}\` |` : null,
|
||||
requestId ? `| Request ID | \`${requestId}\` |` : null,
|
||||
errorCode ? `| Error Code | \`${errorCode}\` |` : null,
|
||||
`| Reported At | ${new Date().toISOString()} |`,
|
||||
"",
|
||||
details ? "### Details\n```json\n" + JSON.stringify(details, null, 2) + "\n```" : null,
|
||||
"",
|
||||
"_Auto-reported by OmniRoute Registered Key Issuer_",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
// ── Log locally regardless ──
|
||||
console.log(
|
||||
`[issues/report] title="${title}" errorCode=${errorCode ?? "—"} provider=${provider ?? "—"} accountId=${accountId ?? "—"}`
|
||||
);
|
||||
|
||||
if (!repo || !token) {
|
||||
// No GitHub config — log only
|
||||
return NextResponse.json(
|
||||
{
|
||||
logged: true,
|
||||
githubIssueCreated: false,
|
||||
reason: !repo ? "GITHUB_ISSUES_REPO not configured" : "GITHUB_ISSUES_TOKEN not configured",
|
||||
},
|
||||
{ status: 202 }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Create GitHub issue ──
|
||||
try {
|
||||
const [owner, repoName] = repo.split("/");
|
||||
const ghRes = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Key Issuer] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["key-issuer", "automated", ...labels],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!ghRes.ok) {
|
||||
const errText = await ghRes.text();
|
||||
console.error(`[issues/report] GitHub API error ${ghRes.status}: ${errText}`);
|
||||
return NextResponse.json(
|
||||
{ logged: true, githubIssueCreated: false, githubError: ghRes.status },
|
||||
{ status: 207 }
|
||||
);
|
||||
}
|
||||
|
||||
const ghData = await ghRes.json();
|
||||
return NextResponse.json({
|
||||
logged: true,
|
||||
githubIssueCreated: true,
|
||||
githubIssueUrl: ghData.html_url,
|
||||
githubIssueNumber: ghData.number,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[issues/report] GitHub fetch failed:", err);
|
||||
return NextResponse.json(
|
||||
{ logged: true, githubIssueCreated: false, error: "GitHub request failed" },
|
||||
{ status: 207 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { setProviderKeyLimit, getProviderKeyLimit } from "@/lib/db/registeredKeys";
|
||||
|
||||
const limitsSchema = z.object({
|
||||
maxActiveKeys: z.number().int().positive().nullable().optional(),
|
||||
dailyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
hourlyIssueLimit: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/providers/[id]/limits
|
||||
* Get the current issuance limits for a provider.
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const limits = getProviderKeyLimit(params.id);
|
||||
return NextResponse.json({ provider: params.id, limits: limits ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/providers/[id]/limits
|
||||
* Configure issuance limits for a provider.
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = limitsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
setProviderKeyLimit(params.id, parsed.data);
|
||||
const updated = getProviderKeyLimit(params.id);
|
||||
return NextResponse.json({ provider: params.id, limits: updated });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { checkQuota } from "@/lib/db/registeredKeys";
|
||||
|
||||
/**
|
||||
* GET /api/v1/quotas/check?provider=&accountId=
|
||||
*
|
||||
* Check if a new registered key can be issued for the given provider/account
|
||||
* without actually issuing one. Use this to pre-validate before POST /registered-keys.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider") ?? "";
|
||||
const accountId = searchParams.get("accountId") ?? "";
|
||||
|
||||
try {
|
||||
const result = checkQuota(provider, accountId);
|
||||
return NextResponse.json({
|
||||
allowed: result.allowed,
|
||||
...(result.errorCode ? { errorCode: result.errorCode, reason: result.errorMessage } : {}),
|
||||
provider: provider || null,
|
||||
accountId: accountId || null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[quotas/check] error:", err);
|
||||
return NextResponse.json({ error: "Quota check failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { revokeRegisteredKey } from "@/lib/db/registeredKeys";
|
||||
|
||||
/**
|
||||
* POST /api/v1/registered-keys/[id]/revoke
|
||||
*
|
||||
* Explicit revoke endpoint (supports clients that cannot issue DELETE requests).
|
||||
*/
|
||||
export async function POST(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const revoked = revokeRegisteredKey(params.id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getRegisteredKey, revokeRegisteredKey } from "@/lib/db/registeredKeys";
|
||||
|
||||
// ─── GET /api/v1/registered-keys/[id] ────────────────────────────────────────
|
||||
|
||||
export async function GET(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const key = getRegisteredKey(params.id);
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ key });
|
||||
}
|
||||
|
||||
// ─── DELETE /api/v1/registered-keys/[id] ─────────────────────────────────────
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const revoked = revokeRegisteredKey(params.id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { issueRegisteredKey, checkQuota, listRegisteredKeys } from "@/lib/db/registeredKeys";
|
||||
|
||||
// ─── Validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
const issueKeySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
provider: z.string().max(80).optional().default(""),
|
||||
accountId: z.string().max(120).optional().default(""),
|
||||
idempotencyKey: z.string().max(256).optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).optional(),
|
||||
dailyBudget: z.number().int().positive().optional(),
|
||||
hourlyBudget: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// ─── GET /api/v1/registered-keys ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List registered keys (masked — no raw key material returned after creation).
|
||||
* Optional query params: ?provider=&accountId=
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider") ?? undefined;
|
||||
const accountId = searchParams.get("accountId") ?? undefined;
|
||||
|
||||
try {
|
||||
const keys = listRegisteredKeys({ provider, accountId });
|
||||
return NextResponse.json({ keys, total: keys.length });
|
||||
} catch (err) {
|
||||
console.error("[registered-keys] GET failed:", err);
|
||||
return NextResponse.json({ error: "Failed to list registered keys" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── POST /api/v1/registered-keys ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Issue a new registered key.
|
||||
*
|
||||
* Checks provider + account quotas before issuing.
|
||||
* Returns the raw key ONCE — it is never stored in plain text.
|
||||
* Subsequent fetches will only return the masked prefix.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = issueKeySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
}
|
||||
|
||||
const { provider, accountId } = parsed.data;
|
||||
|
||||
// ── Quota check ──
|
||||
try {
|
||||
const quota = checkQuota(provider, accountId);
|
||||
if (!quota.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: quota.errorMessage, errorCode: quota.errorCode },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[registered-keys] quota check failed:", err);
|
||||
return NextResponse.json({ error: "Quota check failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
// ── Issue ──
|
||||
try {
|
||||
const result = issueRegisteredKey(parsed.data);
|
||||
|
||||
if ("idempotencyConflict" in result) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Idempotency key already used",
|
||||
errorCode: "IDEMPOTENCY_CONFLICT",
|
||||
existing: result.existing,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const { rawKey, ...keyMeta } = result;
|
||||
return NextResponse.json(
|
||||
{
|
||||
key: rawKey, // ← shown ONCE only
|
||||
keyId: keyMeta.id,
|
||||
keyPrefix: keyMeta.keyPrefix,
|
||||
name: keyMeta.name,
|
||||
provider: keyMeta.provider,
|
||||
accountId: keyMeta.accountId,
|
||||
expiresAt: keyMeta.expiresAt,
|
||||
createdAt: keyMeta.createdAt,
|
||||
warning: "Store this key securely — it will not be shown again.",
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[registered-keys] issue failed:", err);
|
||||
return NextResponse.json({ error: "Failed to issue key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,11 @@ export default function LoginPage() {
|
||||
router.refresh();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
// (#521) If no password is set, redirect to onboarding instead of showing an error
|
||||
if (data.needsSetup) {
|
||||
router.push("/dashboard/onboarding");
|
||||
return;
|
||||
}
|
||||
setError(data.error || t("invalidPassword"));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"failedUpdatePermissionsRetry": "Failed to update permissions. Please try again.",
|
||||
"unknownProvider": "unknown",
|
||||
"copyMaskedKey": "Copy masked key",
|
||||
"keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key",
|
||||
"modelsCount": "{count, plural, one {# model} other {# models}}",
|
||||
"lastUsedOn": "Last: {date}",
|
||||
"editPermissions": "Edit permissions",
|
||||
@@ -1437,6 +1438,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": "正在加载...",
|
||||
|
||||
+57
-2
@@ -38,6 +38,10 @@ interface ApiKeyMetadata {
|
||||
autoResolve: boolean;
|
||||
isActive: boolean;
|
||||
accessSchedule: AccessSchedule | null;
|
||||
maxRequestsPerDay: number | null;
|
||||
maxRequestsPerMinute: number | null;
|
||||
// T08: Per-key max concurrent sticky sessions (0 = unlimited)
|
||||
maxSessions: number;
|
||||
}
|
||||
|
||||
interface ApiKeyRow extends JsonRecord {
|
||||
@@ -187,6 +191,19 @@ 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");
|
||||
}
|
||||
// T08: max concurrent sticky sessions per key (0 = unlimited)
|
||||
if (!columnNames.has("max_sessions")) {
|
||||
db.exec("ALTER TABLE api_keys ADD COLUMN max_sessions INTEGER NOT NULL DEFAULT 0");
|
||||
console.log("[DB] Added api_keys.max_sessions column");
|
||||
}
|
||||
_schemaChecked = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -212,7 +229,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, max_sessions 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 +423,10 @@ export async function updateApiKeyPermissions(
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
// T08: max concurrent sessions for this key (0 = unlimited)
|
||||
maxSessions?: number | null;
|
||||
}
|
||||
) {
|
||||
const db = getDbInstance() as ApiKeysDbLike;
|
||||
@@ -422,6 +443,9 @@ export async function updateApiKeyPermissions(
|
||||
autoResolve: update.autoResolve,
|
||||
isActive: update.isActive,
|
||||
accessSchedule: update.accessSchedule,
|
||||
maxRequestsPerDay: update.maxRequestsPerDay,
|
||||
maxRequestsPerMinute: update.maxRequestsPerMinute,
|
||||
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
|
||||
};
|
||||
|
||||
if (
|
||||
@@ -431,7 +455,10 @@ 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 &&
|
||||
(normalized as Record<string, unknown>).maxSessions === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -446,6 +473,9 @@ export async function updateApiKeyPermissions(
|
||||
autoResolve?: number;
|
||||
isActive?: number;
|
||||
accessSchedule?: string | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
maxSessions?: number;
|
||||
} = { id };
|
||||
|
||||
if (normalized.name !== undefined) {
|
||||
@@ -486,6 +516,22 @@ 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 maxSessionsUpdate = (normalized as Record<string, unknown>).maxSessions;
|
||||
if (maxSessionsUpdate !== undefined) {
|
||||
updates.push("max_sessions = @maxSessions");
|
||||
params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0;
|
||||
}
|
||||
|
||||
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
|
||||
|
||||
if (result.changes === 0) return false;
|
||||
@@ -574,6 +620,11 @@ 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 rawMaxSessions = record.max_sessions ?? record.maxSessions;
|
||||
|
||||
const metadata: ApiKeyMetadata = {
|
||||
id: metadataId,
|
||||
name: metadataName,
|
||||
@@ -586,6 +637,10 @@ 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,
|
||||
// T08: max concurrent sessions; 0 = unlimited (default & backward-compatible)
|
||||
maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0,
|
||||
};
|
||||
|
||||
if (!metadata.id) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Migration 008: Registered Keys Provisioning API (#464)
|
||||
--
|
||||
-- Adds three tables:
|
||||
-- registered_keys — auto-provisioned API keys with quota metadata
|
||||
-- provider_key_limits — per-provider issuance limits
|
||||
-- account_key_limits — per-account issuance limits
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: registered_keys
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS registered_keys (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
key TEXT NOT NULL UNIQUE, -- hashed key material (sha256)
|
||||
key_prefix TEXT NOT NULL, -- first 8 chars for display (e.g. "ork_abc1")
|
||||
name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT '', -- associated provider (optional)
|
||||
account_id TEXT NOT NULL DEFAULT '', -- account/tenant identifier
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
revoked_at TEXT, -- ISO timestamp, null if active
|
||||
expires_at TEXT, -- ISO timestamp, null = no expiry
|
||||
idempotency_key TEXT UNIQUE, -- prevents duplicate issue requests
|
||||
daily_budget INTEGER, -- max requests per day (null = unlimited)
|
||||
hourly_budget INTEGER, -- max requests per hour (null = unlimited)
|
||||
daily_used INTEGER NOT NULL DEFAULT 0,
|
||||
hourly_used INTEGER NOT NULL DEFAULT 0,
|
||||
last_reset_day TEXT NOT NULL DEFAULT '', -- YYYY-MM-DD for daily reset tracking
|
||||
last_reset_hour TEXT NOT NULL DEFAULT '', -- YYYY-MM-DDTHH for hourly reset tracking
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_provider ON registered_keys(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_account ON registered_keys(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_active ON registered_keys(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_registered_keys_idempotency ON registered_keys(idempotency_key);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: provider_key_limits (per-provider issuance limits)
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS provider_key_limits (
|
||||
provider TEXT PRIMARY KEY,
|
||||
max_active_keys INTEGER, -- null = unlimited
|
||||
daily_issue_limit INTEGER, -- max keys per day
|
||||
hourly_issue_limit INTEGER, -- max keys per hour
|
||||
daily_issued INTEGER NOT NULL DEFAULT 0,
|
||||
hourly_issued INTEGER NOT NULL DEFAULT 0,
|
||||
last_reset_day TEXT NOT NULL DEFAULT '',
|
||||
last_reset_hour TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Table: account_key_limits (per-account issuance limits)
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS account_key_limits (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
max_active_keys INTEGER,
|
||||
daily_issue_limit INTEGER,
|
||||
hourly_issue_limit INTEGER,
|
||||
daily_issued INTEGER NOT NULL DEFAULT 0,
|
||||
hourly_issued INTEGER NOT NULL DEFAULT 0,
|
||||
last_reset_day TEXT NOT NULL DEFAULT '',
|
||||
last_reset_hour TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration 009: Add requested_model to call_logs for billing transparency
|
||||
-- Tracks the model the client *asked* for vs the model that was *actually routed*.
|
||||
-- Needed when a combo falls back: requested_model ≠ model in call_logs.
|
||||
-- Ref: sub2api commits 0b845c25 + 4edcfe1f (T01 sub2api gap analysis)
|
||||
ALTER TABLE call_logs ADD COLUMN requested_model TEXT DEFAULT NULL;
|
||||
|
||||
-- Index for filtering/aggregating by requested_model in Analytics
|
||||
CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model
|
||||
ON call_logs(requested_model);
|
||||
+134
-8
@@ -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,13 +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<{
|
||||
normalizeToolCallId: boolean;
|
||||
preserveOpenAIDeveloperRole: boolean | null;
|
||||
}>
|
||||
patch: ModelCompatPatch
|
||||
) {
|
||||
const list = readCompatList(providerId);
|
||||
const idx = list.findIndex((e) => e.id === modelId);
|
||||
@@ -75,9 +130,18 @@ export function mergeModelCompatOverride(
|
||||
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);
|
||||
@@ -281,6 +345,23 @@ export async function updateCustomModel(
|
||||
if (index === -1) return null;
|
||||
|
||||
const current = models[index];
|
||||
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 } : {}),
|
||||
@@ -299,6 +380,13 @@ export async function updateCustomModel(
|
||||
next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (updates.compatByProtocol !== undefined) {
|
||||
if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) {
|
||||
next.compatByProtocol = mergedCompat;
|
||||
} else {
|
||||
delete next.compatByProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
models[index] = next;
|
||||
|
||||
@@ -335,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);
|
||||
}
|
||||
|
||||
@@ -347,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);
|
||||
}
|
||||
|
||||
@@ -513,3 +513,98 @@ export async function deleteProviderNode(id: string) {
|
||||
backupDbFile("pre-write");
|
||||
return rowToCamel(existing);
|
||||
}
|
||||
|
||||
// ──────────────── T05: Rate-Limit DB Persistence ──────────────────────────
|
||||
// Allows rate-limit state to survive token refresh without being accidentally
|
||||
// cleared. DB column rate_limited_until already exists in schema.
|
||||
// Ref: sub2api PR #1218 (fix(openai): prevent rescheduling rate-limited accounts)
|
||||
|
||||
/**
|
||||
* T05: Persist when a connection is rate-limited, directly in DB.
|
||||
* This survives token refresh — OAuth flows must NOT override this field.
|
||||
*
|
||||
* @param connectionId - The provider_connections.id
|
||||
* @param until - Epoch ms when the rate limit expires (null to clear)
|
||||
*/
|
||||
export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
db.prepare(
|
||||
"UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?"
|
||||
).run(until, new Date().toISOString(), connectionId);
|
||||
invalidateDbCache("connections");
|
||||
}
|
||||
|
||||
/**
|
||||
* T05: Check if a connection is currently rate-limited (DB-backed).
|
||||
* Use this before account selection to skip transiently rate-limited accounts.
|
||||
*
|
||||
* @returns true if rate_limited_until is set and in the future
|
||||
*/
|
||||
export function isConnectionRateLimited(connectionId: string): boolean {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const row = db
|
||||
.prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?")
|
||||
.get(connectionId) as { rate_limited_until?: number | null } | undefined;
|
||||
if (!row?.rate_limited_until) return false;
|
||||
return Date.now() < row.rate_limited_until;
|
||||
}
|
||||
|
||||
/**
|
||||
* T05: Get all connections for a provider that are currently rate-limited.
|
||||
* Returns an array of { id, rateLimitedUntil } for dashboard display.
|
||||
*/
|
||||
export function getRateLimitedConnections(
|
||||
provider: string
|
||||
): Array<{ id: string; rateLimitedUntil: number }> {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const now = Date.now();
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, rate_limited_until FROM provider_connections WHERE provider = ? AND rate_limited_until > ?"
|
||||
)
|
||||
.all(provider, now) as Array<{ id: string; rate_limited_until: number }>;
|
||||
return rows.map((r) => ({ id: r.id, rateLimitedUntil: r.rate_limited_until }));
|
||||
}
|
||||
|
||||
// ──────────────── T13: Stale Quota Display Fix ─────────────────────────────
|
||||
// Codex/Claude quotas display stale cumulative usage after the window resets.
|
||||
// By comparing resetAt timestamp to now(), we can show 0 when window has passed.
|
||||
// Ref: sub2api PR #1171 (fix: quota display shows stale cumulative usage after reset)
|
||||
|
||||
/**
|
||||
* T13: Get effective quota usage, zeroing it out if the window has already reset.
|
||||
*
|
||||
* @param used - Stored usage value (tokens used in the window)
|
||||
* @param resetAt - ISO-8601 string or epoch ms when the window resets, or null
|
||||
* @returns Effective usage: 0 if window expired, original value otherwise
|
||||
*/
|
||||
export function getEffectiveQuotaUsage(
|
||||
used: number,
|
||||
resetAt: string | number | null | undefined
|
||||
): number {
|
||||
if (!resetAt) return used;
|
||||
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
||||
if (isNaN(resetTime)) return used;
|
||||
// Window has passed — display should show 0 (pending next snapshot)
|
||||
if (Date.now() >= resetTime) return 0;
|
||||
return used;
|
||||
}
|
||||
|
||||
/**
|
||||
* T13: Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s".
|
||||
* Returns null if resetAt is in the past or not set.
|
||||
*/
|
||||
export function formatResetCountdown(resetAt: string | number | null | undefined): string | null {
|
||||
if (!resetAt) return null;
|
||||
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
|
||||
if (isNaN(resetTime)) return null;
|
||||
const diffMs = resetTime - Date.now();
|
||||
if (diffMs <= 0) return null;
|
||||
const totalSeconds = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* db/registeredKeys.ts — Registered Keys Provisioning (#464)
|
||||
*
|
||||
* Handles:
|
||||
* - Issuing registered keys with idempotency
|
||||
* - Per-provider and per-account quota enforcement
|
||||
* - Key revocation
|
||||
* - Quota status queries for rate-limiting decisions
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance, rowToCamel } from "./core";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegisteredKey {
|
||||
id: string;
|
||||
keyPrefix: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
isActive: boolean;
|
||||
revokedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
idempotencyKey: string | null;
|
||||
dailyBudget: number | null;
|
||||
hourlyBudget: number | null;
|
||||
dailyUsed: number;
|
||||
hourlyUsed: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RegisteredKeyWithSecret extends RegisteredKey {
|
||||
/** Raw key material — only returned once on creation */
|
||||
rawKey: string;
|
||||
}
|
||||
|
||||
export interface ProviderKeyLimit {
|
||||
provider: string;
|
||||
maxActiveKeys: number | null;
|
||||
dailyIssueLimit: number | null;
|
||||
hourlyIssueLimit: number | null;
|
||||
dailyIssued: number;
|
||||
hourlyIssued: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountKeyLimit {
|
||||
accountId: string;
|
||||
maxActiveKeys: number | null;
|
||||
dailyIssueLimit: number | null;
|
||||
hourlyIssueLimit: number | null;
|
||||
dailyIssued: number;
|
||||
hourlyIssued: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface QuotaCheckResult {
|
||||
allowed: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
provider?: string;
|
||||
accountId?: string;
|
||||
providerActiveKeys?: number;
|
||||
accountActiveKeys?: number;
|
||||
}
|
||||
|
||||
export interface IssueKeyParams {
|
||||
name: string;
|
||||
provider?: string;
|
||||
accountId?: string;
|
||||
idempotencyKey?: string;
|
||||
expiresAt?: string;
|
||||
dailyBudget?: number;
|
||||
hourlyBudget?: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function nowDay(): string {
|
||||
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
function nowHour(): string {
|
||||
return new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
}
|
||||
|
||||
function hashKey(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
function generateRawKey(): string {
|
||||
// ork_ prefix so users can easily identify these keys
|
||||
return "ork_" + randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
/** Reset window counters if the tracking period has changed. */
|
||||
function maybeResetWindow(
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
table: string,
|
||||
idField: string,
|
||||
idValue: string
|
||||
): void {
|
||||
const today = nowDay();
|
||||
const hour = nowHour();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE ${table}
|
||||
SET daily_issued = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_issued END,
|
||||
hourly_issued = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_issued END,
|
||||
last_reset_day = ?,
|
||||
last_reset_hour = ?
|
||||
WHERE ${idField} = ?
|
||||
`
|
||||
).run(today, hour, today, hour, idValue);
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a new registered key can be issued for the given provider/account.
|
||||
* Returns { allowed: true } or { allowed: false, errorCode, errorMessage }.
|
||||
*/
|
||||
export function checkQuota(provider = "", accountId = ""): QuotaCheckResult {
|
||||
const db = getDbInstance();
|
||||
const today = nowDay();
|
||||
const hour = nowHour();
|
||||
|
||||
// ── provider-level check ──
|
||||
if (provider) {
|
||||
maybeResetWindow(db, "provider_key_limits", "provider", provider);
|
||||
|
||||
const limits = db
|
||||
.prepare("SELECT * FROM provider_key_limits WHERE provider = ?")
|
||||
.get(provider) as ProviderKeyLimitRow | undefined;
|
||||
|
||||
if (limits) {
|
||||
if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "PROVIDER_QUOTA_EXCEEDED",
|
||||
errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for provider '${provider}'`,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "PROVIDER_QUOTA_EXCEEDED",
|
||||
errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for provider '${provider}'`,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
if (limits.max_active_keys !== null) {
|
||||
const { activeCount } = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as activeCount FROM registered_keys WHERE provider = ? AND is_active = 1"
|
||||
)
|
||||
.get(provider) as { activeCount: number };
|
||||
if (activeCount >= limits.max_active_keys) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "MAX_ACTIVE_KEYS_EXCEEDED",
|
||||
errorMessage: `Max active keys (${limits.max_active_keys}) reached for provider '${provider}'`,
|
||||
provider,
|
||||
providerActiveKeys: activeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── account-level check ──
|
||||
if (accountId) {
|
||||
maybeResetWindow(db, "account_key_limits", "account_id", accountId);
|
||||
|
||||
const limits = db
|
||||
.prepare("SELECT * FROM account_key_limits WHERE account_id = ?")
|
||||
.get(accountId) as AccountKeyLimitRow | undefined;
|
||||
|
||||
if (limits) {
|
||||
if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "ACCOUNT_QUOTA_EXCEEDED",
|
||||
errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for account '${accountId}'`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "ACCOUNT_QUOTA_EXCEEDED",
|
||||
errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for account '${accountId}'`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
if (limits.max_active_keys !== null) {
|
||||
const { activeCount } = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as activeCount FROM registered_keys WHERE account_id = ? AND is_active = 1"
|
||||
)
|
||||
.get(accountId) as { activeCount: number };
|
||||
if (activeCount >= limits.max_active_keys) {
|
||||
return {
|
||||
allowed: false,
|
||||
errorCode: "MAX_ACTIVE_KEYS_EXCEEDED",
|
||||
errorMessage: `Max active keys (${limits.max_active_keys}) reached for account '${accountId}'`,
|
||||
accountId,
|
||||
accountActiveKeys: activeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a new registered key.
|
||||
* Returns the key with rawKey (only on creation) or null if idempotency_key already exists.
|
||||
*/
|
||||
export function issueRegisteredKey(
|
||||
params: IssueKeyParams
|
||||
): RegisteredKeyWithSecret | { idempotencyConflict: true; existing: RegisteredKey } {
|
||||
const db = getDbInstance();
|
||||
const {
|
||||
name,
|
||||
provider = "",
|
||||
accountId = "",
|
||||
idempotencyKey,
|
||||
expiresAt,
|
||||
dailyBudget,
|
||||
hourlyBudget,
|
||||
} = params;
|
||||
|
||||
// ── idempotency check ──
|
||||
if (idempotencyKey) {
|
||||
const existing = db
|
||||
.prepare("SELECT * FROM registered_keys WHERE idempotency_key = ?")
|
||||
.get(idempotencyKey) as RegisteredKeyRow | undefined;
|
||||
if (existing) {
|
||||
return {
|
||||
idempotencyConflict: true,
|
||||
existing: rowToCamel(existing) as unknown as RegisteredKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rawKey = generateRawKey();
|
||||
const id = uuidv4();
|
||||
const keyHash = hashKey(rawKey);
|
||||
const keyPrefix = rawKey.slice(0, 12); // "ork_" + 8 chars
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO registered_keys
|
||||
(id, key, key_prefix, name, provider, account_id, idempotency_key, expires_at, daily_budget, hourly_budget, last_reset_day, last_reset_hour)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
id,
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
name,
|
||||
provider,
|
||||
accountId,
|
||||
idempotencyKey ?? null,
|
||||
expiresAt ?? null,
|
||||
dailyBudget ?? null,
|
||||
hourlyBudget ?? null,
|
||||
nowDay(),
|
||||
nowHour()
|
||||
);
|
||||
|
||||
// Increment provider/account issuance counters
|
||||
if (provider) {
|
||||
maybeResetWindow(db, "provider_key_limits", "provider", provider);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_key_limits (provider, daily_issued, hourly_issued, last_reset_day, last_reset_hour)
|
||||
VALUES (?, 1, 1, ?, ?)
|
||||
ON CONFLICT(provider) DO UPDATE SET
|
||||
daily_issued = daily_issued + 1,
|
||||
hourly_issued = hourly_issued + 1,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(provider, nowDay(), nowHour());
|
||||
}
|
||||
if (accountId) {
|
||||
maybeResetWindow(db, "account_key_limits", "account_id", accountId);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO account_key_limits (account_id, daily_issued, hourly_issued, last_reset_day, last_reset_hour)
|
||||
VALUES (?, 1, 1, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
daily_issued = daily_issued + 1,
|
||||
hourly_issued = hourly_issued + 1,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(accountId, nowDay(), nowHour());
|
||||
}
|
||||
|
||||
const created = db
|
||||
.prepare("SELECT * FROM registered_keys WHERE id = ?")
|
||||
.get(id) as RegisteredKeyRow;
|
||||
return { ...(rowToCamel(created) as unknown as RegisteredKey), rawKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered key by ID (without the raw key — only prefix is returned).
|
||||
*/
|
||||
export function getRegisteredKey(id: string): RegisteredKey | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM registered_keys WHERE id = ?").get(id) as
|
||||
| RegisteredKeyRow
|
||||
| undefined;
|
||||
return row ? (rowToCamel(row) as unknown as RegisteredKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered keys (optionally filtered by provider/accountId).
|
||||
*/
|
||||
export function listRegisteredKeys(
|
||||
opts: { provider?: string; accountId?: string } = {}
|
||||
): RegisteredKey[] {
|
||||
const db = getDbInstance();
|
||||
let sql = "SELECT * FROM registered_keys WHERE 1=1";
|
||||
const args: string[] = [];
|
||||
if (opts.provider) {
|
||||
sql += " AND provider = ?";
|
||||
args.push(opts.provider);
|
||||
}
|
||||
if (opts.accountId) {
|
||||
sql += " AND account_id = ?";
|
||||
args.push(opts.accountId);
|
||||
}
|
||||
sql += " ORDER BY created_at DESC LIMIT 500";
|
||||
const rows = db.prepare(sql).all(...args) as RegisteredKeyRow[];
|
||||
return rows.map((r) => rowToCamel(r) as unknown as RegisteredKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a registered key by ID.
|
||||
*/
|
||||
export function revokeRegisteredKey(id: string): boolean {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE registered_keys
|
||||
SET is_active = 0, revoked_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ? AND is_active = 1
|
||||
`
|
||||
)
|
||||
.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw registered key against stored hashes.
|
||||
* Returns the key metadata if valid, null otherwise.
|
||||
*/
|
||||
export function validateRegisteredKey(rawKey: string): RegisteredKey | null {
|
||||
const db = getDbInstance();
|
||||
const hash = hashKey(rawKey);
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM registered_keys
|
||||
WHERE key = ? AND is_active = 1
|
||||
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
||||
`
|
||||
)
|
||||
.get(hash) as RegisteredKeyRow | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
// Auto-reset budget windows if needed
|
||||
const today = nowDay();
|
||||
const hour = nowHour();
|
||||
if (row.last_reset_day !== today || row.last_reset_hour !== hour) {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE registered_keys
|
||||
SET daily_used = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_used END,
|
||||
hourly_used = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_used END,
|
||||
last_reset_day = ?, last_reset_hour = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
).run(today, hour, today, hour, row.id);
|
||||
}
|
||||
|
||||
// Budget check
|
||||
if (row.daily_budget !== null && row.daily_used >= row.daily_budget) return null;
|
||||
if (row.hourly_budget !== null && row.hourly_used >= row.hourly_budget) return null;
|
||||
|
||||
return rowToCamel(row) as unknown as RegisteredKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage counters for a registered key (called by request pipeline).
|
||||
*/
|
||||
export function incrementRegisteredKeyUsage(id: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE registered_keys
|
||||
SET daily_used = daily_used + 1, hourly_used = hourly_used + 1, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`
|
||||
).run(id);
|
||||
}
|
||||
|
||||
// ─── Provider / Account Limit Management ──────────────────────────────────────
|
||||
|
||||
export function setProviderKeyLimit(
|
||||
provider: string,
|
||||
limits: Partial<Omit<ProviderKeyLimit, "provider" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_key_limits (provider, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider) DO UPDATE SET
|
||||
max_active_keys = excluded.max_active_keys,
|
||||
daily_issue_limit = excluded.daily_issue_limit,
|
||||
hourly_issue_limit = excluded.hourly_issue_limit,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(
|
||||
provider,
|
||||
limits.maxActiveKeys ?? null,
|
||||
limits.dailyIssueLimit ?? null,
|
||||
limits.hourlyIssueLimit ?? null,
|
||||
nowDay(),
|
||||
nowHour()
|
||||
);
|
||||
}
|
||||
|
||||
export function setAccountKeyLimit(
|
||||
accountId: string,
|
||||
limits: Partial<Omit<AccountKeyLimit, "accountId" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
|
||||
): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO account_key_limits (account_id, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
max_active_keys = excluded.max_active_keys,
|
||||
daily_issue_limit = excluded.daily_issue_limit,
|
||||
hourly_issue_limit = excluded.hourly_issue_limit,
|
||||
updated_at = datetime('now')
|
||||
`
|
||||
).run(
|
||||
accountId,
|
||||
limits.maxActiveKeys ?? null,
|
||||
limits.dailyIssueLimit ?? null,
|
||||
limits.hourlyIssueLimit ?? null,
|
||||
nowDay(),
|
||||
nowHour()
|
||||
);
|
||||
}
|
||||
|
||||
export function getProviderKeyLimit(provider: string): ProviderKeyLimit | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM provider_key_limits WHERE provider = ?").get(provider) as
|
||||
| ProviderKeyLimitRow
|
||||
| undefined;
|
||||
return row ? (rowToCamel(row) as unknown as ProviderKeyLimit) : null;
|
||||
}
|
||||
|
||||
export function getAccountKeyLimit(accountId: string): AccountKeyLimit | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM account_key_limits WHERE account_id = ?").get(accountId) as
|
||||
| AccountKeyLimitRow
|
||||
| undefined;
|
||||
return row ? (rowToCamel(row) as unknown as AccountKeyLimit) : null;
|
||||
}
|
||||
|
||||
// ─── Internal types (raw DB rows) ─────────────────────────────────────────────
|
||||
|
||||
interface RegisteredKeyRow {
|
||||
id: string;
|
||||
key: string;
|
||||
key_prefix: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
account_id: string;
|
||||
is_active: number;
|
||||
revoked_at: string | null;
|
||||
expires_at: string | null;
|
||||
idempotency_key: string | null;
|
||||
daily_budget: number | null;
|
||||
hourly_budget: number | null;
|
||||
daily_used: number;
|
||||
hourly_used: number;
|
||||
last_reset_day: string;
|
||||
last_reset_hour: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ProviderKeyLimitRow {
|
||||
provider: string;
|
||||
max_active_keys: number | null;
|
||||
daily_issue_limit: number | null;
|
||||
hourly_issue_limit: number | null;
|
||||
daily_issued: number;
|
||||
hourly_issued: number;
|
||||
last_reset_day: string;
|
||||
last_reset_hour: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AccountKeyLimitRow {
|
||||
account_id: string;
|
||||
max_active_keys: number | null;
|
||||
daily_issue_limit: number | null;
|
||||
hourly_issue_limit: number | null;
|
||||
daily_issued: number;
|
||||
hourly_issued: number;
|
||||
last_reset_day: string;
|
||||
last_reset_hour: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
/**
|
||||
* T07: Extract the real client IP from X-Forwarded-For header.
|
||||
* Skips invalid entries like "unknown" or empty strings.
|
||||
* Falls back to remoteAddress if no valid IP found.
|
||||
* Ref: sub2api PR #1135
|
||||
*
|
||||
* @param xForwardedFor - Value of the X-Forwarded-For header (may be CSV)
|
||||
* @param remoteAddress - Fallback from the raw socket (req.socket.remoteAddress)
|
||||
* @returns The first valid IP address found, or "unknown"
|
||||
*/
|
||||
export function extractClientIp(
|
||||
xForwardedFor: string | null | undefined,
|
||||
remoteAddress: string | undefined
|
||||
): string {
|
||||
if (xForwardedFor) {
|
||||
const entries = xForwardedFor.split(",");
|
||||
for (const entry of entries) {
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed && isIP(trimmed) !== 0) {
|
||||
return trimmed; // First valid IP wins
|
||||
}
|
||||
}
|
||||
}
|
||||
return remoteAddress?.trim() ?? "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract client IP from a Request or NextRequest object.
|
||||
* Checks X-Forwarded-For, X-Real-IP, CF-Connecting-IP, then socket.
|
||||
*/
|
||||
export function getClientIpFromRequest(req: {
|
||||
headers?: Headers | { get?: (n: string) => string | null };
|
||||
socket?: { remoteAddress?: string };
|
||||
ip?: string;
|
||||
}): string {
|
||||
// Helper to get header value from either Headers object or plain object
|
||||
const getHeader = (name: string): string | null => {
|
||||
if (!req.headers) return null;
|
||||
if (typeof (req.headers as Headers).get === "function") {
|
||||
return (req.headers as Headers).get(name);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Priority: CF-Connecting-IP (Cloudflare) > X-Forwarded-For > X-Real-IP > socket
|
||||
const cfIp = getHeader("cf-connecting-ip");
|
||||
if (cfIp && isIP(cfIp.trim()) !== 0) return cfIp.trim();
|
||||
|
||||
const xff = getHeader("x-forwarded-for");
|
||||
const realIp = getHeader("x-real-ip");
|
||||
const remoteAddress = req.ip ?? req.socket?.remoteAddress;
|
||||
|
||||
return extractClientIp(xff ?? realIp, remoteAddress);
|
||||
}
|
||||
@@ -23,6 +23,15 @@ export {
|
||||
createProviderNode,
|
||||
updateProviderNode,
|
||||
deleteProviderNode,
|
||||
|
||||
// T05: Rate-limit DB persistence (survives token refresh)
|
||||
setConnectionRateLimitUntil,
|
||||
isConnectionRateLimited,
|
||||
getRateLimitedConnections,
|
||||
|
||||
// T13: Stale quota display fix (zero out usage after window resets)
|
||||
getEffectiveQuotaUsage,
|
||||
formatResetCountdown,
|
||||
} from "./db/providers";
|
||||
|
||||
export {
|
||||
@@ -48,6 +57,8 @@ export {
|
||||
getModelPreserveOpenAIDeveloperRole,
|
||||
} from "./db/models";
|
||||
|
||||
export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models";
|
||||
|
||||
export {
|
||||
// Combos
|
||||
getCombos,
|
||||
@@ -136,3 +147,27 @@ export {
|
||||
getCachedProviderConnections,
|
||||
invalidateDbCache,
|
||||
} from "./db/readCache";
|
||||
|
||||
export {
|
||||
// Registered Keys Provisioning (#464)
|
||||
issueRegisteredKey,
|
||||
getRegisteredKey,
|
||||
listRegisteredKeys,
|
||||
revokeRegisteredKey,
|
||||
validateRegisteredKey,
|
||||
incrementRegisteredKeyUsage,
|
||||
checkQuota,
|
||||
setProviderKeyLimit,
|
||||
setAccountKeyLimit,
|
||||
getProviderKeyLimit,
|
||||
getAccountKeyLimit,
|
||||
} from "./db/registeredKeys";
|
||||
|
||||
export type {
|
||||
RegisteredKey,
|
||||
RegisteredKeyWithSecret,
|
||||
ProviderKeyLimit,
|
||||
AccountKeyLimit,
|
||||
QuotaCheckResult,
|
||||
IssueKeyParams,
|
||||
} from "./db/registeredKeys";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user