Compare commits

...

49 Commits

Author SHA1 Message Date
diegosouzapw ce2c30c437 chore(release): v2.6.8 — combo agents, auto-update, detailed logs, MITM Kiro
Build Electron Desktop App / Validate version (push) Failing after 31s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-17 08:58:03 -03:00
diegosouzapw d56fae0a7b feat: combo agents, auto-update UI, detailed logs, MITM Kiro (#399 #401 #320 #378 #336)
DB Migrations (zero-breaking, ADD COLUMN DEFAULT NULL + new table):
- 005_combo_agent_fields.sql: system_message, tool_filter_regex, context_cache_protection on combos
- 006_detailed_request_logs.sql: ring-buffer table (500 entries) for full pipeline body capture

Features:
- #399 System Message Override + Tool Filter Regex per Combo
  - applyComboAgentMiddleware() injected into handleComboChat/handleRoundRobinCombo
  - Supports both OpenAI and Anthropic tool name formats
- #401 Context Caching Protection (Stateless)
  - injectModelTag() appends <omniModel>provider/model</omniModel> to responses
  - extractPinnedModel() reads tag from history and pins model for session
- #320 Auto-Update via Settings
  - GET /api/system/version — current vs latest npm
  - POST /api/system/update — fire-and-forget npm install + pm2 restart
- #378 Detailed Request Logs
  - saveRequestDetailLog() captures bodies at 4 pipeline stages (opt-in toggle)
  - GET/POST /api/logs/detail — list logs + enable/disable toggle
- #336 MITM Kiro IDE
  - src/mitm/targets/kiro.ts: MitmTarget profile for api.anthropic.com interception
2026-03-17 08:53:41 -03:00
diegosouzapw e45ef00bef chore(release): v2.6.7 — SSE fixes, local provider_nodes, proxy registry
Build Electron Desktop App / Validate version (push) Failing after 32s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
PRs merged: #414 (deps) #415 #417 #419 #420 #421 (SSE fixes)
            #418 (Claude passthrough) #422 #416 #423 (local nodes)
            #427 (strip empty blocks) #428 (OAuth refreshable)
            #429 (proxy registry)
Contributors: @prakersh, @Regis-RCR, @dependabot
2026-03-17 08:17:11 -03:00
diegosouzapw e9f31f7394 Merge pull request #429 from contributor branch 2026-03-17 08:14:05 -03:00
diegosouzapw 7c10a98eb2 Merge pull request #428 from contributor branch 2026-03-17 08:14:04 -03:00
diegosouzapw f260483101 Merge pull request #427 from contributor branch 2026-03-17 08:14:03 -03:00
diegosouzapw 389e6e5c9e Merge pull request #423 from contributor branch 2026-03-17 08:14:02 -03:00
diegosouzapw 1cfd5866be Merge pull request #422 from contributor branch 2026-03-17 08:14:02 -03:00
diegosouzapw c7ceac7f41 Merge pull request #421 from contributor branch 2026-03-17 08:14:01 -03:00
diegosouzapw cd6eca0424 Merge pull request #420 from contributor branch 2026-03-17 08:14:00 -03:00
diegosouzapw 8c6136fea0 fix(sse): generate fallback call_id for tool calls with missing IDs (#419)
Co-authored-by: Prakersh Maheshwari <prakersh@users.noreply.github.com>
2026-03-17 08:11:53 -03:00
Diego Rodrigues de Sa e Souza 9644444028 Merge pull request #418 from prakersh/fix/claude-to-claude-passthrough
fix(sse): add Claude-to-Claude passthrough for anthropic-compatible providers
2026-03-17 08:09:44 -03:00
Diego Rodrigues de Sa e Souza 9c4154291d Merge pull request #417 from prakersh/fix/orphaned-tool-result-filter
fix(sse): filter orphaned tool results after context compaction
2026-03-17 08:09:41 -03:00
Diego Rodrigues de Sa e Souza 533f5f6da6 Merge pull request #416 from Regis-RCR/feat/audio-provider-nodes
feat(audio): route audio requests to local provider_nodes
2026-03-17 08:09:38 -03:00
Diego Rodrigues de Sa e Souza 1b8de756cd Merge pull request #415 from prakersh/fix/empty-tool-name-loop
fix(sse): skip empty-name tool calls in Responses API translator
2026-03-17 08:09:28 -03:00
Diego Rodrigues de Sa e Souza 650b415537 Merge pull request #414 from diegosouzapw/dependabot/npm_and_yarn/development-cc00f57801
deps: bump the development group with 4 updates
2026-03-17 08:09:25 -03:00
rexname 04b50329fc fix(proxy): address PR review findings for auth, credentials, and health stats 2026-03-17 16:58:44 +07:00
Regis 25aab8c55c feat(audio): route audio requests to local provider_nodes
Audio endpoints (/v1/audio/speech and /v1/audio/transcriptions) only
supported hardcoded providers from audioRegistry.ts. Local inference
backends configured as provider_nodes (e.g., MLX-Audio, oMLX) could
not serve audio through OmniRoute.

This adds a Phase 3 fallback in the audio model parser that consults
provider_nodes from the database. Local providers with api_type=openai
are automatically available for audio routing via their prefix
(e.g., mlx-audio/tts-model, omlx/whisper-large-v3-turbo).

Design: injection pattern — Next.js route handlers load provider_nodes
(async DB query) and pass them to the sync parser as a parameter.
No cross-workspace imports, no breaking changes to existing parsers.

Changes:
- Add buildDynamicAudioProvider() in audioRegistry.ts
- Add Phase 3 (provider_nodes prefix match) to parseAudioModel()
- Extend parseSpeechModel/parseTranscriptionModel with optional
  dynamicProviders parameter (backward compatible)
- Load and inject provider_nodes in speech/transcription route handlers
- Dynamic providers use authType=none (local, no credentials needed)
2026-03-17 09:24:18 +01:00
Oleg Saprykin ceda2e70c1 fix(api): add refreshable: true to claude OAuth test config
Claude OAuth tokens are short-lived and require refresh. The runtime
HealthCheck (open-sse) already refreshes them successfully, but the
Dashboard test endpoint was missing `refreshable: true` in its config.

This caused the Dashboard to show "auth failed / Token expired" for
Claude providers even though the tokens were being refreshed correctly
at runtime. The codex provider already had this flag set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:47:35 +03:00
Oleg Saprykin 2908303d4b fix(sse): strip empty text content blocks before translation
Anthropic API rejects requests containing {"type":"text","text":""} with
400 "text content blocks must be non-empty". Some clients like LiteLLM
passthrough and @ai-sdk/anthropic may forward empty text blocks as-is.

Filter out empty text content blocks from messages before calling
translateRequest, similar to how empty-name tools are already stripped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:46:24 +03:00
diegosouzapw a9f69711c6 fix(build): remove node: protocol prefix from all src/ imports (#turbopack-compat)
Build Electron Desktop App / Validate version (push) Failing after 39s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Turbopack (Next.js 15) does not process node: URL prefixes correctly when
bundling server-side files that get transitively included. Removed the node:
prefix from 17 files:

- src/lib/db/migrationRunner.ts (node:fs, node:path, node:url)
- src/lib/db/core.ts (node:path, node:fs)
- src/lib/db/backup.ts (node:path, node:fs)
- src/lib/db/prompts.ts (node:fs)
- src/lib/dataPaths.ts (node:path, node:os)
- src/app/api/settings/route.ts
- src/app/api/storage/health/route.ts
- src/app/api/oauth/[provider]/[action]/route.ts
- src/app/api/db-backups/{exportAll,import,export}/route.ts
- src/shared/middleware/correlationId.ts
- src/shared/utils/requestId.ts
- src/lib/apiBridgeServer.ts
- src/lib/cacheLayer.ts
- src/lib/semanticCache.ts
- src/lib/oauth/providers/kimi-coding.ts

Also updated generate-release.md: Docker Hub sync and dual-VPS deploy
are now mandatory steps in every release.
2026-03-17 04:24:46 -03:00
diegosouzapw a8ab16a720 chore(release): v2.6.5 — reasoning params filter, local 404 fix, Kilo Gateway, dep bumps
Build Electron Desktop App / Validate version (push) Failing after 24s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- fix(sse): strip unsupported params for o1/o1-mini/o1-pro/o3/o3-mini (PR #412 @Regis-RCR)
- fix(sse): model-only lockout (5s) for local provider 404 (PR #410 @Regis-RCR)
- feat(api): Kilo Gateway provider — 335+ models, alias 'kg' (PR #408 @Regis-RCR)
- deps: better-sqlite3 12.8, undici 7.24.4, https-proxy-agent 8 (PR #413)
2026-03-17 03:05:45 -03:00
rexname 8091b6b508 feat: implement proxy registry, management APIs, docs, and test hardening 2026-03-17 13:05:27 +07:00
Diego Rodrigues de Sa e Souza a00ef0fc7e Merge pull request #413 from diegosouzapw/dependabot/npm_and_yarn/production-4d4ff746af
deps: bump the production group with 5 updates
2026-03-17 03:03:49 -03:00
Diego Rodrigues de Sa e Souza 5ce6d615a4 Merge pull request #408 from Regis-RCR/feat/kilo-gateway-provider
feat(api): add Kilo Gateway provider
2026-03-17 03:03:47 -03:00
Diego Rodrigues de Sa e Souza e06b69cdac Merge pull request #410 from Regis-RCR/fix/local-404-cascade
fix(sse): model-only lockout for local provider 404
2026-03-17 03:03:31 -03:00
Diego Rodrigues de Sa e Souza d261ae7883 Merge pull request #412 from Regis-RCR/fix/param-filter-reasoning
fix(sse): strip unsupported params for reasoning models (o1/o3)
2026-03-17 03:03:28 -03:00
diegosouzapw 6fa77a63d7 chore(release): v2.6.4 — model name fixes across providers 2026-03-17 01:59:25 -03:00
diegosouzapw f76c1b32d6 fix(providers): remove non-existent model names and fix incorrect model IDs
- gemini/gemini-cli: removed gemini-3.1-pro/flash/preview (don't exist in Google API v1beta),
  replaced with real models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, gemini-1.5-*
- antigravity: removed gemini-3.1-pro-high/low and gemini-3-flash (internal aliases invalid),
  replaced with gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash
- github: removed gemini-3-flash-preview and gemini-3-pro-preview, replaced with gemini-2.5-flash
- nvidia: corrected 'nvidia/llama-3.3-70b-instruct' to 'meta/llama-3.3-70b-instruct'
  (NVIDIA NIM uses meta/ namespace, not nvidia/ namespace for Meta models)
- nvidia: added meta/llama-3.1-70b-instruct and nvidia/llama-3.1-405b-instruct

Also fixed free-stack combo on .15 DB:
- removed qw/qwen3-coder-plus (qwen provider has expired refresh token)
- corrected nvidia/llama-3.3-70b-instruct → nvidia/meta/llama-3.3-70b-instruct
- corrected gemini/gemini-3.1-flash → gemini/gemini-2.5-flash
- added if/deepseek-v3.2 as replacement for qw/qwen3-coder-plus
2026-03-17 01:48:40 -03:00
Regis 0aede2ef63 feat(health): background health check for local provider_nodes
Local inference backends (oMLX, Ollama, LM Studio) configured as
provider_nodes have no health monitoring. When a local provider is
down, OmniRoute waits the full timeout before failing.

This adds a background health check that polls local provider_nodes:
- GET /models with 5s timeout for each local node (localhost only)
- In-memory health cache (no DB migration needed)
- Promise.allSettled for parallel checks (one slow node doesn't block)
- Exponential backoff on failures: 30s → 60s → 120s → 300s max
- Reset to 30s on first success after failure
- State transition logging (healthy ↔ unhealthy)
- Expose health status via GET /api/monitoring/health (localProviders)
- Auto-init on first import (same pattern as tokenHealthCheck)
- 401 treated as healthy (server up, auth required)
- isNodeHealthy() returns true if never checked (optimistic default)
2026-03-16 22:44:43 +01:00
Regis 1e3a2e0a27 feat(embeddings): route embedding requests to local provider_nodes
Embedding endpoint (/v1/embeddings) only supports 6 hardcoded cloud
providers. Local inference backends (oMLX, Ollama) serving embeddings
via provider_nodes are inaccessible through OmniRoute.

This adds dynamic provider_node support for embeddings:
- Add EmbeddingProvider interface and buildDynamicEmbeddingProvider()
- Add Phase 2 (provider_nodes prefix match) in parseEmbeddingModel()
- Handler accepts resolvedProvider/resolvedModel from route (injection pattern)
- Handler supports authType=none for local providers (was missing — critical gap)
- Route loads local provider_nodes (localhost only — prevents auth bypass/SSRF)
- Route filters by apiType=chat|responses and localhost hostname
- buildDynamicEmbeddingProvider validates inputs (prefix + baseUrl required)
- Per-node try/catch in map — one bad row doesn't block all providers
- DB errors logged and fall back to hardcoded providers
2026-03-16 22:15:49 +01:00
Prakersh Maheshwari 1bdabf43db fix: prevent mutation of original request body in Claude passthrough
Use shallow copy ({ ...body }) instead of direct reference assignment
so that later translatedBody.model = model does not mutate the
caller's original body object.
2026-03-17 02:45:21 +05:30
Prakersh Maheshwari 05e568feb0 fix(sse): extract Claude SSE usage in passthrough stream mode 2026-03-17 02:41:54 +05:30
Prakersh Maheshwari 81e2519436 refactor: replace as any casts with explicit inline types
Addresses PR review: use `{ id?: string }[]` and
`{ type?: string; call_id?: string }` instead of `any`.
2026-03-17 02:40:36 +05:30
Prakersh Maheshwari ef623c9bb5 refactor: trim function name consistently in Responses-to-Chat direction
Addresses PR review: both translation directions now trim the function
name the same way, matching the Chat-to-Responses pattern.
2026-03-17 02:35:42 +05:30
Prakersh Maheshwari da581525a6 fix(sse): strip Claude-specific fields in OpenAI format cleanup 2026-03-17 02:16:26 +05:30
Prakersh Maheshwari 6ff7b6570c fix(sse): add Claude-to-Claude passthrough for anthropic-compatible providers
When both source and target formats are Claude, skip all request
modification and forward the body untouched. This prevents
prepareClaudeRequest from corrupting valid Claude-native requests
destined for anthropic-compatible provider nodes.
2026-03-17 02:03:45 +05:30
Prakersh Maheshwari 8b2081837e fix(sse): filter orphaned tool results after context compaction
When Claude Code compacts conversation context to fit within token
limits, it may remove assistant messages containing tool_use/tool_calls
while leaving the corresponding tool_result/function_call_output
messages intact. This creates orphaned tool results that cause
providers to reject requests with errors like "tool result's tool id
not found" or "No tool call found for function call output".
2026-03-17 01:59:40 +05:30
Prakersh Maheshwari ce978b602a fix(sse): skip empty-name tool calls in Responses API translator
Prevents infinite retry loops when models generate tool calls with
empty function names. The normalizeToolName function converted these
to "placeholder_tool" which does not exist in any client's tool
registry, causing repeated error-retry cycles.
2026-03-17 01:47:22 +05:30
dependabot[bot] 9b00f5d550 deps: bump the development group with 4 updates
Bumps the development group with 4 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [lint-staged](https://github.com/lint-staged/lint-staged), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@types/node` from 25.4.0 to 25.5.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `lint-staged` from 16.3.2 to 16.4.0
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v16.3.2...v16.4.0)

Updates `typescript-eslint` from 8.57.0 to 8.57.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.57.1/packages/typescript-eslint)

Updates `vitest` from 4.0.18 to 4.1.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: lint-staged
  dependency-version: 16.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: typescript-eslint
  dependency-version: 8.57.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 19:04:07 +00:00
dependabot[bot] d98ec59c79 deps: bump the production group with 5 updates
Bumps the production group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.6.2` | `12.8.0` |
| [https-proxy-agent](https://github.com/TooTallNate/proxy-agents/tree/HEAD/packages/https-proxy-agent) | `7.0.6` | `8.0.0` |
| [undici](https://github.com/nodejs/undici) | `7.24.2` | `7.24.4` |
| [wreq-js](https://github.com/sqdshguy/wreq-js) | `2.1.1` | `2.2.0` |
| [zustand](https://github.com/pmndrs/zustand) | `5.0.11` | `5.0.12` |


Updates `better-sqlite3` from 12.6.2 to 12.8.0
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.6.2...v12.8.0)

Updates `https-proxy-agent` from 7.0.6 to 8.0.0
- [Release notes](https://github.com/TooTallNate/proxy-agents/releases)
- [Changelog](https://github.com/TooTallNate/proxy-agents/blob/main/packages/https-proxy-agent/CHANGELOG.md)
- [Commits](https://github.com/TooTallNate/proxy-agents/commits/https-proxy-agent@8.0.0/packages/https-proxy-agent)

Updates `undici` from 7.24.2 to 7.24.4
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.2...v7.24.4)

Updates `wreq-js` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/sqdshguy/wreq-js/releases)
- [Commits](https://github.com/sqdshguy/wreq-js/compare/v2.1.1...v2.2.0)

Updates `zustand` from 5.0.11 to 5.0.12
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.11...v5.0.12)

---
updated-dependencies:
- dependency-name: better-sqlite3
  dependency-version: 12.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: https-proxy-agent
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: undici
  dependency-version: 7.24.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: wreq-js
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: zustand
  dependency-version: 5.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 19:03:12 +00:00
Regis d79b55be5a fix(sse): strip unsupported params for reasoning models (o1/o3)
Reasoning models (o1, o1-pro, o3, o3-mini) reject standard parameters
like temperature and top_p with 400 Bad Request. OmniRoute's default
executor forwards all parameters without filtering.

This fix adds declarative parameter filtering:
- Add unsupportedParams[] field to RegistryModel interface
- Add REASONING_UNSUPPORTED frozen constant shared across entries
- Add o1-pro, o3, o3-mini to OpenAI registry (were missing)
- Add getUnsupportedParams() helper with:
  - O(1) precomputed map lookup (not O(N×M) scan)
  - Cross-provider routing support via precomputed map
  - Prefixed model ID support (e.g., "openai/o3" → "o3")
- Strip unsupported params in chatCore.ts before executor call
- Use Object.hasOwn() for safe property check (no prototype chain)
- Log stripped params at WARN level for visibility
2026-03-16 19:41:55 +01:00
Regis 1f9a402dcd fix(sse): address bot review — tighten local detection, guard null model
- Remove apiKey===null heuristic (too broad — could match cloud providers
  with non-standard auth). Use URL-based detection only.
- Guard local 404 branch with provider && model check — if either is null,
  fall through to standard connection lockout (safer behavior).
- Document LOCAL_HOSTNAMES as module-load-time constant (restart required).
- Document PROVIDER_PROFILES.local as intentionally not yet wired.
2026-03-16 19:03:47 +01:00
Regis f9bcc9418b fix(sse): model-only lockout for local provider 404 (connection stays active)
When a local inference backend (oMLX, Ollama, LM Studio) returns 404
for an unknown model, OmniRoute previously locked the entire connection
for 2 minutes — blocking all valid models on that connection.

This fix introduces local provider detection and changes the 404
behavior for local providers:
- Model-only lockout (5s) instead of connection-level lockout (2min)
- Connection stays active — other models continue working immediately
- Detection via URL heuristic (localhost/127.0.0.1) + apiKey===null fallback
- Configurable via LOCAL_HOSTNAMES env var for Docker setups

Also fixes a pre-existing bug where the model parameter was not passed
to markAccountUnavailable() from chat.ts, preventing per-model lockouts
from working at all.

Changes:
- Add isLocalProvider(baseUrl) helper in providerRegistry.ts
- Add COOLDOWN_MS.notFoundLocal (5s) and PROVIDER_PROFILES.local
- Add local 404 branch in markAccountUnavailable() in auth.ts
- Pass model param to markAccountUnavailable() in chat.ts (bug fix)
2026-03-16 18:55:41 +01:00
Regis 08256a3502 feat(api): add Kilo Gateway provider (335+ models, 6 free, auto-routing)
Kilo Gateway (api.kilo.ai/api/gateway) is an OpenAI-compatible API
offering 335+ models via a single API key, including 6 free models
and 3 auto-routing models (frontier/balanced/free).

This is distinct from the existing KiloCode provider which uses
OAuth + /api/openrouter/ endpoint.

- Register kilo-gateway in providerRegistry.ts (alias: kg)
- Add to APIKEY_PROVIDERS in providers.ts
- Add models endpoint config in route.ts
- Add official Kilo AI icon (favicon)
2026-03-16 17:26:27 +01:00
diegosouzapw 9b255e643a chore(release): v2.6.3 — compile-time hash-strip fix, Synthetic provider (PR #404), VPS PM2 path fix
Build Electron Desktop App / Validate version (push) Failing after 42s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-16 11:00:43 -03:00
Diego Rodrigues de Sa e Souza ca1f918e9e Merge pull request #404 from Regis-RCR/feat/synthetic-provider
feat(api): add Synthetic as a new API key provider
2026-03-16 10:59:13 -03:00
diegosouzapw bb3fe1cd48 fix(build): strip Turbopack hashed require() from compiled server chunks in prepublish
Even with EXPERIMENTAL_TURBOPACK=0 and NEXT_PRIVATE_BUILD_WORKER=0, Next.js 16
instrumentation chunks still emit require('better-sqlite3-<16hexchars>') and
require('zod-<16hexchars>') into the compiled .js files inside .next/server/.

The webpack externals function in next.config.mjs patches the runtime bundler
but does NOT rewrite already-compiled chunks. Added step 5.6 to prepublish.mjs:
walks all .js files in app/.next/server/ and strips the 16-char hex suffix from
any require() string that matches the Turbopack hash pattern.

Also updated deploy-vps workflow: npm registry rejects 299MB packages, so
deployment now uses npm pack + scp + npm install -g /tmp/omniroute-*.tgz.
PM2 entry point is app/server.js inside the npm global package.
2026-03-16 10:46:27 -03:00
Regis d139b4557f feat(api): add Synthetic as a new API key provider
Add Synthetic (synthetic.new) as a privacy-focused LLM provider
with OpenAI-compatible API, dynamic model catalog via /models
endpoint, and passthrough model support.

- Register provider in providerRegistry.ts with 6 initial models
- Add APIKEY_PROVIDERS entry with verified_user icon (#6366F1)
- Add models listing config for /api/providers/[id]/models endpoint
- passthroughModels enabled for dynamic model catalog
2026-03-16 12:39:23 +01:00
94 changed files with 6196 additions and 865 deletions
+35 -27
View File
@@ -4,73 +4,81 @@ description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
# Deploy to VPS Workflow
Deploy OmniRoute to the production VPS using `npm install -g` + PM2.
Deploy OmniRoute to the production VPS using `npm pack + scp` + PM2.
**VPS:** `69.164.221.35` (Akamai, Ubuntu 24.04, 1GB RAM + 2.5GB swap)
**Local VPS:** `192.168.0.15` (same setup)
**Process manager:** PM2 (`omniroute`)
**Port:** `20128`
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
> [!IMPORTANT]
> PM2 runs from the global npm package at `/usr/lib/node_modules/omniroute`.
> **DO NOT** use git clone or local copies. The `npm install -g` command handles
> building, publishing, and installing the standalone app in one step.
> The Next.js standalone build is at `app/server.js` inside that directory.
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
## Steps
### 1. Publish to npm
### 1. Build + pack locally
Ensure the version in `package.json` is bumped and the package is published:
Run the full build (includes hash-strip patch) and create the .tgz:
// turbo
```bash
npm publish
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
```
### 2. Install on VPS and restart PM2
### 2. Copy to both VPS and install
// turbo-all
```bash
ssh root@69.164.221.35 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
```
For the local VPS:
```bash
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save && echo '✅ Akamai done'"
```
```bash
ssh root@192.168.0.15 "npm install -g omniroute@latest && pm2 restart omniroute && pm2 save && echo '✅ Deploy complete!'"
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save && echo '✅ Local done'"
```
### 3. Verify the deployment
```bash
ssh root@69.164.221.35 "pm2 list && cat \$(npm root -g)/omniroute/package.json | grep version | head -1 && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
ssh root@69.164.221.35 "pm2 list && cat \$(npm root -g)/omniroute/app/package.json | grep version | head -1 && curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:20128/"
```
Expected: PM2 shows `online`, version matches published, HTTP returns `307` (redirect to login).
Expected: PM2 shows `online`, version matches, HTTP returns `307`.
## How it works
1. `npm publish` builds Next.js standalone + bundles everything into the npm package
2. `npm install -g omniroute@latest` downloads and installs to `/usr/lib/node_modules/omniroute/`
3. PM2 is registered to run `npm start` from that directory (cwd: `/usr/lib/node_modules/omniroute`)
4. `pm2 restart omniroute` picks up the new code immediately
1. `npm run build:cli` builds Next.js standalone `app/` and strips Turbopack hashed require() calls from chunks
2. `npm pack --ignore-scripts` packages without re-running the build
3. `scp` transfers the .tgz to each VPS (~286MB)
4. `npm install -g /tmp/omniroute-*.tgz --ignore-scripts` installs pre-built package
5. PM2 runs `app/server.js` from `/usr/lib/node_modules/omniroute`
## PM2 Setup (one-time)
If PM2 needs to be reconfigured from scratch:
## PM2 Setup (one-time — if reconfiguring from scratch)
```bash
ssh root@<VPS> "
cd /usr/lib/node_modules/omniroute &&
PORT=20128 pm2 start app/server.js --name omniroute --env PORT=20128 &&
pm2 save &&
pm2 startup
pm2 delete omniroute ;
cp /opt/omniroute-app/.env /usr/lib/node_modules/omniroute/.env &&
PORT=20128 pm2 start /usr/lib/node_modules/omniroute/app/server.js --name omniroute --cwd /usr/lib/node_modules/omniroute/app &&
pm2 save && pm2 startup
"
```
> [!NOTE]
> Copy `.env` from the old installation first. For Akamai it was at `/opt/omniroute-app/.env`,
> for the local VPS it was at `/root/omniroute-fresh/.env`.
## Notes
- The `.env` file is at `/usr/lib/node_modules/omniroute/.env`. Back it up before major npm updates.
- PM2 is configured with `pm2 startup` to auto-restart on reboot.
- Nginx proxies `omniroute.online``localhost:20128`.
- The VPS has only 1GB RAM — builds happen locally via `npm publish`, not on the VPS.
- `.env` should be placed at `/usr/lib/node_modules/omniroute/app/.env`
- PM2 is configured with `pm2 startup` to auto-restart on reboot
- Nginx proxies `omniroute.online``localhost:20128`
- The VPS has only 1GB RAM — builds happen locally, never on the VPS
+40 -3
View File
@@ -85,12 +85,49 @@ git push origin main --tags
gh release create v2.x.y --title "v2.x.y — summary" --notes "..."
```
### 8. Deploy to VPS (if requested)
### 8. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
See `/deploy-vps` workflow for Akamai VPS or use npm for local VPS:
> **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**:
```bash
ssh root@<VPS_IP> "npm install -g omniroute@2.x.y && pm2 restart omniroute"
# Verify the Docker workflow triggered
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
# Wait for the Docker build to complete (usually 510 min)
gh run watch --repo diegosouzapw/OmniRoute
# After completion, verify on Docker Hub:
# https://hub.docker.com/r/diegosouzapw/omniroute/tags
```
If the Docker build was not triggered automatically, trigger it manually:
```bash
gh workflow run docker-publish.yml --repo diegosouzapw/OmniRoute --ref v2.x.y
```
### 9. Deploy to BOTH VPS environments (MANDATORY)
> Always deploy to **both** environments after every release.
> See `/deploy-vps` workflow for detailed steps.
```bash
# Build and pack locally
cd /home/diegosouzapw/dev/proxys/9router && npm run build:cli && npm pack --ignore-scripts
# Deploy to LOCAL VPS (192.168.0.15)
scp omniroute-*.tgz root@192.168.0.15:/tmp/
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save"
# Deploy to AKAMAI VPS (69.164.221.35)
scp omniroute-*.tgz root@69.164.221.35:/tmp/
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && pm2 restart omniroute && pm2 save"
# Verify both
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/
```
## Notes
+2 -2
View File
@@ -21,8 +21,8 @@ This workflow fetches all open issues from the project's GitHub repository, clas
// turbo
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 100 --json number,title,labels,body,comments,createdAt,author`
- Parse the JSON output to get a list of all open issues
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number,title,labels,body,comments,createdAt,author`
- Parse the JSON output to get a list of **all** open issues
- Sort by oldest first (FIFO)
### 3. Classify Each Issue
+5 -1
View File
@@ -18,7 +18,11 @@ This workflow fetches all open PRs from the project's GitHub repository, perform
### 2. Fetch Open Pull Requests
- Navigate to `https://github.com/<owner>/<repo>/pulls` and scrape all open PRs
// turbo
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number,title,author,headRefName,body,createdAt,additions,deletions,files`
- This fetches **all** open PRs without restriction. Get the diff for each with:
`gh pr diff <NUMBER> --repo <owner>/<repo>`
- For each open PR, collect:
- PR number, title, author, branch, number of commits, date
- PR description/body
+114
View File
@@ -4,6 +4,120 @@
---
## [2.6.8] — 2026-03-17
> Sprint: Combo as Agent (system prompt + tool filter), Context Caching Protection, Auto-Update, Detailed Logs, MITM Kiro IDE.
### 🗄️ DB Migrations (zero-breaking — safe for existing users)
- **005_combo_agent_fields.sql**: `ALTER TABLE combos ADD COLUMN system_message TEXT DEFAULT NULL`, `tool_filter_regex TEXT DEFAULT NULL`, `context_cache_protection INTEGER DEFAULT 0`
- **006_detailed_request_logs.sql**: New `request_detail_logs` table with 500-entry ring-buffer trigger, opt-in via settings toggle
### ✨ Features
- **feat(combo)**: System Message Override per Combo (#399`system_message` field replaces or injects system prompt before forwarding to provider)
- **feat(combo)**: Tool Filter Regex per Combo (#399`tool_filter_regex` keeps only tools matching pattern; supports OpenAI + Anthropic formats)
- **feat(combo)**: Context Caching Protection (#401`context_cache_protection` tags responses with `<omniModel>provider/model</omniModel>` and pins model for session continuity)
- **feat(settings)**: Auto-Update via Settings (#320`GET /api/system/version` + `POST /api/system/update` — checks npm registry and updates in background with pm2 restart)
- **feat(logs)**: Detailed Request Logs (#378 — captures full pipeline bodies at 4 stages: client request, translated request, provider response, client response — opt-in toggle, 64KB trim, 500-entry ring-buffer)
- **feat(mitm)**: MITM Kiro IDE profile (#336`src/mitm/targets/kiro.ts` targets api.anthropic.com, reuses existing MITM infrastructure)
---
## [2.6.7] — 2026-03-17
> Sprint: SSE improvements, local provider_nodes extensions, proxy registry, Claude passthrough fixes.
### ✨ Features
- **feat(health)**: Background health check for local `provider_nodes` with exponential backoff (30s→300s) and `Promise.allSettled` to avoid blocking (#423, @Regis-RCR)
- **feat(embeddings)**: Route `/v1/embeddings` to local `provider_nodes``buildDynamicEmbeddingProvider()` with hostname validation (#422, @Regis-RCR)
- **feat(audio)**: Route TTS/STT to local `provider_nodes``buildDynamicAudioProvider()` with SSRF protection (#416, @Regis-RCR)
- **feat(proxy)**: Proxy registry, management APIs, and quota-limit generalization (#429, @Regis-RCR)
### 🐛 Bug Fixes
- **fix(sse)**: Strip Claude-specific fields (`metadata`, `anthropic_version`) when target is OpenAI-compat (#421, @prakersh)
- **fix(sse)**: Extract Claude SSE usage (`input_tokens`, `output_tokens`, cache tokens) in passthrough stream mode (#420, @prakersh)
- **fix(sse)**: Generate fallback `call_id` for tool calls with missing/empty IDs (#419, @prakersh)
- **fix(sse)**: Claude-to-Claude passthrough — forward body completely untouched, no re-translation (#418, @prakersh)
- **fix(sse)**: Filter orphaned `tool_result` items after Claude Code context compaction to avoid 400 errors (#417, @prakersh)
- **fix(sse)**: Skip empty-name tool calls in Responses API translator to prevent `placeholder_tool` infinite loops (#415, @prakersh)
- **fix(sse)**: Strip empty text content blocks before translation (#427, @prakersh)
- **fix(api)**: Add `refreshable: true` to Claude OAuth test config (#428, @prakersh)
### 📦 Dependencies
- Bump `vitest`, `@vitest/*` and related devDependencies (#414, @dependabot)
---
## [2.6.6] — 2026-03-17
> Hotfix: Turbopack/Docker compatibility — remove `node:` protocol from all `src/` imports.
### 🐛 Bug Fixes
- **fix(build)**: Removed `node:` protocol prefix from `import` statements in 17 files under `src/`. The `node:fs`, `node:path`, `node:url`, `node:os` etc. imports caused `Ecmascript file had an error` on Turbopack builds (Next.js 15 Docker) and on upgrades from older npm global installs. Affected files: `migrationRunner.ts`, `core.ts`, `backup.ts`, `prompts.ts`, `dataPaths.ts`, and 12 others in `src/app/api/` and `src/lib/`.
- **chore(workflow)**: Updated `generate-release.md` to make Docker Hub sync and dual-VPS deploy **mandatory** steps in every release.
---
## [2.6.5] — 2026-03-17
> Sprint: reasoning model param filtering, local provider 404 fix, Kilo Gateway provider, dependency bumps.
### ✨ New Features
- **feat(api)**: Added **Kilo Gateway** (`api.kilo.ai`) as a new API Key provider (alias `kg`) — 335+ models, 6 free models, 3 auto-routing models (`kilo-auto/frontier`, `kilo-auto/balanced`, `kilo-auto/free`). Passthrough models supported via `/api/gateway/models` endpoint. (PR #408 by @Regis-RCR)
### 🐛 Bug Fixes
- **fix(sse)**: Strip unsupported parameters for reasoning models (o1, o1-mini, o1-pro, o3, o3-mini). Models in the `o1`/`o3` family reject `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `logprobs`, `top_logprobs`, and `n` with HTTP 400. Parameters are now stripped at the `chatCore` layer before forwarding. Uses a declarative `unsupportedParams` field per model and a precomputed O(1) Map for lookup. (PR #412 by @Regis-RCR)
- **fix(sse)**: Local provider 404 now results in a **model-only lockout (5 seconds)** instead of a connection-level lockout (2 minutes). When a local inference backend (Ollama, LM Studio, oMLX) returns 404 for an unknown model, the connection remains active and other models continue working immediately. Also fixes a pre-existing bug where `model` was not passed to `markAccountUnavailable()`. Local providers detected via hostname (`localhost`, `127.0.0.1`, `::1`, extensible via `LOCAL_HOSTNAMES` env var). (PR #410 by @Regis-RCR)
### 📦 Dependencies
- `better-sqlite3` 12.6.2 → 12.8.0
- `undici` 7.24.2 → 7.24.4
- `https-proxy-agent` 7 → 8
- `agent-base` 7 → 8
---
## [2.6.4] — 2026-03-17
### 🐛 Bug Fixes
- **fix(providers)**: Removed non-existent model names across 5 providers:
- **gemini / gemini-cli**: removed `gemini-3.1-pro/flash` and `gemini-3-*-preview` (don't exist in Google API v1beta); replaced with `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.0-flash`, `gemini-1.5-pro/flash`
- **antigravity**: removed `gemini-3.1-pro-high/low` and `gemini-3-flash` (invalid internal aliases); replaced with real 2.x models
- **github (Copilot)**: removed `gemini-3-flash-preview` and `gemini-3-pro-preview`; replaced with `gemini-2.5-flash`
- **nvidia**: corrected `nvidia/llama-3.3-70b-instruct``meta/llama-3.3-70b-instruct` (NVIDIA NIM uses `meta/` namespace for Meta models); added `nvidia/llama-3.1-70b-instruct` and `nvidia/llama-3.1-405b-instruct`
- **fix(db/combo)**: Updated `free-stack` combo on remote DB: removed `qw/qwen3-coder-plus` (expired refresh token), corrected `nvidia/llama-3.3-70b-instruct``nvidia/meta/llama-3.3-70b-instruct`, corrected `gemini/gemini-3.1-flash``gemini/gemini-2.5-flash`, added `if/deepseek-v3.2`
---
## [2.6.3] — 2026-03-16
> Sprint: zod/pino hash-strip baked into build pipeline, Synthetic provider added, VPS PM2 path corrected.
### 🐛 Bug Fixes
- **fix(build)**: Turbopack hash-strip now runs at **compile time** for ALL packages — not just `better-sqlite3`. Step 5.6 in `prepublish.mjs` walks every `.js` in `app/.next/server/` and strips the 16-char hex suffix from any hashed `require()`. Fixes `zod-dcb22c...`, `pino-...`, etc. MODULE_NOT_FOUND on global npm installs. Closes #398
- **fix(deploy)**: PM2 on both VPS was pointing to stale git-clone directories. Reconfigured to `app/server.js` in the npm global package. Updated `/deploy-vps` workflow to use `npm pack + scp` (npm registry rejects 299MB packages).
### ✨ Features
- **feat(provider)**: Synthetic ([synthetic.new](https://synthetic.new)) — privacy-focused OpenAI-compatible inference. `passthroughModels: true` for dynamic HuggingFace model catalog. Initial models: Kimi K2.5, MiniMax M2.5, GLM 4.7, DeepSeek V3.2. (PR #404 by @Regis-RCR)
### 📋 Issues Closed
- **close #398**: npm hash regression — fixed by compile-time hash-strip in prepublish
- **triage #324**: Bug screenshot without steps — requested reproduction details
---
## [2.6.2] — 2026-03-16
> Sprint: module hashing fully fixed, 2 PRs merged (Anthropic tools filter + custom endpoint paths), Alibaba Cloud DashScope provider added, 3 stale issues closed.
@@ -0,0 +1,46 @@
# ADR-0001: Proxy Registry + Usage Control Generalization
Date: 2026-03-17
Status: Accepted
## Context
OmniRoute sudah punya:
- Proxy assignment berbasis config-map (`global`, `providers`, `combos`, `keys`).
- Quota-aware selection khusus provider tertentu (notably `codex`).
Gap utama:
- Proxy belum menjadi aset reusable yang bisa di-manage sebagai entitas (metadata, where-used, safe delete).
- Usage policy belum konsisten lintas provider.
- Error contract API belum seragam untuk endpoint manajemen.
## Decision
1. Tambah **Proxy Registry** sebagai domain baru di DB (`proxy_registry`, `proxy_assignments`).
2. Pertahankan kompatibilitas assignment lama (fallback ke `proxyConfig` lama).
3. Resolver runtime pakai prioritas:
- account -> provider -> global (registry)
- fallback ke legacy resolver jika registry belum ada assignment
4. Wajib redaction kredensial di output list registry default.
5. Standarkan error JSON untuk endpoint manajemen proxy agar konsisten dan punya `requestId`.
## Consequences
Positif:
- Proxy reusable dan bisa dilacak pemakaiannya.
- Safe delete bisa ditegakkan (409 saat masih dipakai).
- Migrasi bertahap tanpa breaking change runtime.
Negatif:
- Ada dual-source sementara (registry + legacy config) sampai migrasi selesai.
- Butuh endpoint assignment tambahan dan pemetaan scope yang konsisten.
## Follow-up
- Migrasi UI provider/account dari input raw proxy ke selector registry.
- Tambah health telemetry per proxy dan alerting.
- Generalisasi usage control ke provider lain melalui interface policy yang sama.
@@ -0,0 +1,32 @@
# ADR-0002: Error Contract for Management Endpoints
Date: 2026-03-17
Status: Accepted
## Decision
Management endpoints (proxy config, proxy registry, and proxy assignments) return a uniform error body:
```json
{
"error": {
"message": "Human-readable summary",
"type": "invalid_request | not_found | conflict | server_error",
"details": {}
},
"requestId": "uuid"
}
```
## Status Mapping
- 400: invalid request / validation failure
- 404: resource not found
- 409: resource conflict (for example, proxy still assigned)
- 500: unexpected server error
## Notes
- `requestId` is mandatory for log correlation.
- `details` is optional and only used for safe validation details.
- Sensitive secrets (proxy credentials, tokens) must never appear in `message` or `details`.
@@ -0,0 +1,16 @@
# ADR-0003: Security Checklist for Proxy Registry and Usage Controls
Date: 2026-03-17
Status: Accepted
## Checklist
- Validate all management payloads with Zod.
- Reject malformed scope assignment updates with status 400.
- Reject deleting an in-use proxy with status 409 unless forced.
- Never expose proxy username/password in list responses by default.
- Never log raw credentials or token values.
- Keep error responses free from internal stack traces.
- Protect management endpoints with existing auth middleware policy.
- Audit mutating operations: create/update/delete/assign/migrate.
- Ensure resolver fallback to legacy config while migration is in transition.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.6.2
version: 2.6.8
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,
+46 -8
View File
@@ -11,7 +11,7 @@ interface AudioModel {
name: string;
}
interface AudioProvider {
export interface AudioProvider {
id: string;
baseUrl: string;
authType: string;
@@ -262,36 +262,74 @@ export function getSpeechProvider(providerId: string): AudioProvider | null {
return AUDIO_SPEECH_PROVIDERS[providerId] || null;
}
export interface ProviderNodeRow {
prefix: string;
name: string;
baseUrl: string;
apiType?: string;
}
/**
* Parse audio model string (format: "provider/model" or just "model")
* Build a dynamic AudioProvider from a provider_node DB entry.
* Only used for local providers (localhost/127.0.0.1) — remote nodes are
* excluded by the caller to prevent auth bypass and SSRF.
*/
export function buildDynamicAudioProvider(node: ProviderNodeRow, audioPath: string): AudioProvider {
if (!node.prefix || !node.baseUrl) {
throw new Error(`Invalid provider_node: missing prefix or baseUrl`);
}
const baseUrl = node.baseUrl.replace(/\/+$/, "");
return {
id: node.prefix,
baseUrl: `${baseUrl}${audioPath}`,
authType: "none",
authHeader: "none",
models: [],
};
}
function parseAudioModel(
modelStr: string | null,
registry: Record<string, AudioProvider>
registry: Record<string, AudioProvider>,
dynamicProviders?: AudioProvider[]
): { provider: string | null; model: string | null } {
if (!modelStr) return { provider: null, model: null };
for (const [providerId, config] of Object.entries(registry)) {
// Phase 1: prefix match in hardcoded registry
for (const [providerId] of Object.entries(registry)) {
if (modelStr.startsWith(providerId + "/")) {
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
}
}
// Phase 2: bare model lookup in hardcoded registry
for (const [providerId, config] of Object.entries(registry)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
}
}
// Phase 3: prefix match in dynamic providers (provider_nodes)
if (dynamicProviders) {
for (const dp of dynamicProviders) {
if (modelStr.startsWith(dp.id + "/")) {
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
}
}
}
return { provider: null, model: modelStr };
}
export function parseTranscriptionModel(modelStr: string | null) {
return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS);
export function parseTranscriptionModel(
modelStr: string | null,
dynamicProviders?: AudioProvider[]
) {
return parseAudioModel(modelStr, AUDIO_TRANSCRIPTION_PROVIDERS, dynamicProviders);
}
export function parseSpeechModel(modelStr: string | null) {
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS);
export function parseSpeechModel(modelStr: string | null, dynamicProviders?: AudioProvider[]) {
return parseAudioModel(modelStr, AUDIO_SPEECH_PROVIDERS, dynamicProviders);
}
/**
+11
View File
@@ -135,6 +135,7 @@ export const COOLDOWN_MS = {
unauthorized: 2 * 60 * 1000, // 401 → 2 min
paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min
notFound: 2 * 60 * 1000, // 404 → 2 minutes
notFoundLocal: 5 * 1000, // 404 on local provider → 5s model-only lockout (connection stays active)
transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here)
transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s
transient: 5 * 1000, // Legacy alias → points to transientInitial
@@ -162,6 +163,16 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: 5, // More tolerant (occasional 502 is normal)
circuitBreakerReset: 30000, // 30s reset
},
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
// are integrated into the resilience layer. Kept here to avoid a second constants change.
local: {
transientCooldown: 2000, // 2s (local — very fast recovery)
rateLimitCooldown: 5000, // 5s (local — no real rate limits)
maxBackoffLevel: 3, // Low ceiling (local either works or doesn't)
circuitBreakerThreshold: 2, // Opens fast (if local is down, it's down)
circuitBreakerReset: 15000, // 15s reset (check again quickly)
},
};
// Default rate limit values for API Key providers (auto-enabled safety net)
+54 -8
View File
@@ -8,7 +8,43 @@
* keyed by provider ID (e.g. "nebius", "openai").
*/
export const EMBEDDING_PROVIDERS = {
export interface EmbeddingProvider {
id: string;
baseUrl: string;
authType: string;
authHeader: string;
models: { id: string; name: string; dimensions?: number }[];
}
export interface EmbeddingProviderNodeRow {
prefix: string;
name: string;
baseUrl: string;
apiType?: string;
}
/**
* Build a dynamic EmbeddingProvider from a local provider_node.
* Only used for local providers (localhost) — caller must filter by hostname.
*/
export function buildDynamicEmbeddingProvider(node: EmbeddingProviderNodeRow): EmbeddingProvider {
if (!node.prefix || !node.baseUrl) {
throw new Error(`Invalid provider_node: missing prefix or baseUrl`);
}
if (node.prefix.includes("/") || node.prefix.includes(" ")) {
throw new Error(`Invalid provider_node prefix "${node.prefix}": must not contain / or spaces`);
}
const baseUrl = node.baseUrl.replace(/\/+$/, "");
return {
id: node.prefix,
baseUrl: `${baseUrl}/embeddings`,
authType: "none",
authHeader: "none",
models: [],
};
}
export const EMBEDDING_PROVIDERS: Record<string, EmbeddingProvider> = {
nebius: {
id: "nebius",
baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings",
@@ -70,7 +106,7 @@ export const EMBEDDING_PROVIDERS = {
/**
* Get embedding provider config by ID
*/
export function getEmbeddingProvider(providerId) {
export function getEmbeddingProvider(providerId: string): EmbeddingProvider | null {
return EMBEDDING_PROVIDERS[providerId] || null;
}
@@ -78,26 +114,36 @@ export function getEmbeddingProvider(providerId) {
* Parse embedding model string (format: "provider/model" or just "model")
* Returns { provider, model }
*/
export function parseEmbeddingModel(modelStr) {
export function parseEmbeddingModel(
modelStr: string | null,
dynamicProviders?: EmbeddingProvider[]
): { provider: string | null; model: string | null } {
if (!modelStr) return { provider: null, model: null };
// Check for "provider/model" format
const slashIdx = modelStr.indexOf("/");
if (slashIdx > 0) {
// Handle nested model IDs like "nebius/Qwen/Qwen3-Embedding-8B"
// Try each provider prefix
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
// Phase 1: Try each hardcoded provider prefix
for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) {
if (modelStr.startsWith(providerId + "/")) {
return { provider: providerId, model: modelStr.slice(providerId.length + 1) };
}
}
// Fallback: first segment is provider
// Phase 2: Try dynamic provider_nodes prefix
if (dynamicProviders) {
for (const dp of dynamicProviders) {
if (modelStr.startsWith(dp.id + "/")) {
return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) };
}
}
}
// Phase 3: Fallback — first segment is provider
const provider = modelStr.slice(0, slashIdx);
const model = modelStr.slice(slashIdx + 1);
return { provider, model };
}
// No provider prefix — search all providers for the model
// No provider prefix — search hardcoded providers for the model
for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) {
if (config.models.some((m) => m.id === modelStr)) {
return { provider: providerId, model: modelStr };
+142 -18
View File
@@ -12,8 +12,21 @@ export interface RegistryModel {
id: string;
name: string;
targetFormat?: string;
unsupportedParams?: readonly string[];
}
// Reasoning models reject temperature, top_p, penalties, logprobs, n.
// Frozen to prevent accidental mutation (shared across all model entries).
const REASONING_UNSUPPORTED: readonly string[] = Object.freeze([
"temperature",
"top_p",
"frequency_penalty",
"presence_penalty",
"logprobs",
"top_logprobs",
"n",
]);
export interface RegistryOAuth {
clientIdEnv?: string;
clientIdDefault?: string;
@@ -126,13 +139,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientSecretDefault: "",
},
models: [
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3.1-flash", name: "Gemini 3.1 Flash" },
{ id: "gemini-3-pro-preview", name: "Gemini 3.0 Pro Preview" },
{ id: "gemini-3-flash-preview", name: "Gemini 3.0 Flash Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash Exp" },
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
],
},
@@ -155,13 +168,12 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientSecretDefault: "",
},
models: [
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3.1-flash", name: "Gemini 3.1 Flash" },
{ id: "gemini-3-flash-preview", name: "Gemini 3.0 Flash Preview" },
{ id: "gemini-3-pro-preview", name: "Gemini 3.0 Pro Preview" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
],
},
@@ -305,10 +317,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
models: [
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3.1-flash", name: "Gemini 3.1 Flash" },
{ id: "gemini-3-flash", name: "Gemini 3.0 Flash" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
],
},
@@ -356,8 +367,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "oswe-vscode-prime", name: "Raptor Mini" },
],
@@ -429,8 +439,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
{ id: "o1", name: "O1" },
{ id: "o1-mini", name: "O1 Mini" },
{ id: "o1", name: "O1", unsupportedParams: REASONING_UNSUPPORTED },
{ id: "o1-mini", name: "O1 Mini", unsupportedParams: REASONING_UNSUPPORTED },
{ id: "o1-pro", name: "O1 Pro", unsupportedParams: REASONING_UNSUPPORTED },
{ id: "o3", name: "O3", unsupportedParams: REASONING_UNSUPPORTED },
{ id: "o3-mini", name: "O3 Mini", unsupportedParams: REASONING_UNSUPPORTED },
],
},
@@ -836,12 +849,14 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "meta/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
{ id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" },
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
{ id: "nvidia/llama-3.1-70b-instruct", name: "Llama 3.1 70B" },
{ id: "nvidia/llama-3.1-405b-instruct", name: "Llama 3.1 405B" },
],
},
@@ -919,6 +934,46 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
synthetic: {
id: "synthetic",
alias: "synthetic",
format: "openai",
executor: "default",
baseUrl: "https://api.synthetic.new/openai/v1/chat/completions",
modelsUrl: "https://api.synthetic.new/openai/v1/models",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "hf:nvidia/Kimi-K2.5-NVFP4", name: "Kimi K2.5 (NVFP4)" },
{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "hf:zai-org/GLM-4.7-Flash", name: "GLM 4.7 Flash" },
{ id: "hf:zai-org/GLM-4.7", name: "GLM 4.7" },
{ id: "hf:moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
{ id: "hf:deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" },
],
passthroughModels: true,
},
"kilo-gateway": {
id: "kilo-gateway",
alias: "kg",
format: "openai",
executor: "default",
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
modelsUrl: "https://api.kilo.ai/api/gateway/models",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier" },
{ id: "kilo-auto/balanced", name: "Kilo Auto Balanced" },
{ id: "kilo-auto/free", name: "Kilo Auto Free" },
{ id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)" },
{ id: "minimax/minimax-m2.5:free", name: "MiniMax M2.5 (Free)" },
{ id: "arcee-ai/trinity-large-preview:free", name: "Trinity Large Preview (Free)" },
],
passthroughModels: true,
},
vertex: {
id: "vertex",
alias: "vertex",
@@ -1022,6 +1077,38 @@ export function generateAliasMap(): Record<string, string> {
return map;
}
// ── Local Provider Detection ──────────────────────────────────────────────
// Evaluated once at module load time — process restart required for env var changes.
const LOCAL_HOSTNAMES = new Set([
"localhost",
"127.0.0.1",
"::1",
"[::1]",
...(typeof process !== "undefined" && process.env.LOCAL_HOSTNAMES
? process.env.LOCAL_HOSTNAMES.split(",")
.map((h) => h.trim())
.filter(Boolean)
: []),
]);
/**
* Detect if a base URL points to a local inference backend.
* Used for shorter 404 cooldowns (model-only, not connection) and health check targets.
*
* Operators can extend via LOCAL_HOSTNAMES env var (comma-separated) for Docker
* hostnames (e.g., LOCAL_HOSTNAMES=omlx,mlx-audio).
*/
export function isLocalProvider(baseUrl?: string | null): boolean {
if (!baseUrl) return false;
try {
const url = new URL(baseUrl);
return LOCAL_HOSTNAMES.has(url.hostname);
} catch {
return false;
}
}
// ── Registry Lookup Helpers ───────────────────────────────────────────────
const _byAlias = new Map<string, RegistryEntry>();
@@ -1041,6 +1128,43 @@ export function getRegisteredProviders(): string[] {
return Object.keys(REGISTRY);
}
// Precomputed map: modelId → unsupportedParams (O(1) lookup instead of O(N×M) scan).
// Built once at module load from all registry entries.
const _unsupportedParamsMap = new Map<string, readonly string[]>();
for (const entry of Object.values(REGISTRY)) {
for (const model of entry.models) {
if (model.unsupportedParams && !_unsupportedParamsMap.has(model.id)) {
_unsupportedParamsMap.set(model.id, model.unsupportedParams);
}
}
}
/**
* Get unsupported parameters for a specific model.
* Uses O(1) precomputed lookup. Also handles prefixed model IDs
* (e.g., "openai/o3" → strips prefix and looks up "o3").
* Returns empty array if no restrictions are defined.
*/
export function getUnsupportedParams(provider: string, modelId: string): readonly string[] {
// 1. Check current provider's registry (exact match)
const entry = getRegistryEntry(provider);
const modelEntry = entry?.models.find((m) => m.id === modelId);
if (modelEntry?.unsupportedParams) return modelEntry.unsupportedParams;
// 2. O(1) lookup in precomputed map (handles cross-provider routing)
const cached = _unsupportedParamsMap.get(modelId);
if (cached) return cached;
// 3. Handle prefixed model IDs (e.g., "openai/o3" → "o3")
if (modelId.includes("/")) {
const bareId = modelId.split("/").pop() || "";
const bare = _unsupportedParamsMap.get(bareId);
if (bare) return bare;
}
return [];
}
/**
* Get provider category: "oauth" or "apikey"
* Used by the resilience layer to apply different cooldown/backoff profiles.
+16 -4
View File
@@ -381,7 +381,12 @@ async function handleTortoiseSpeech(providerConfig, body) {
* @returns {Response}
*/
/** @returns {Promise<unknown>} */
export async function handleAudioSpeech({ body, credentials }) {
export async function handleAudioSpeech({
body,
credentials,
resolvedProvider = null,
resolvedModel = null,
}) {
if (!body.model) {
return errorResponse(400, "model is required");
}
@@ -389,8 +394,15 @@ export async function handleAudioSpeech({ body, credentials }) {
return errorResponse(400, "input is required");
}
const { provider: providerId, model: modelId } = parseSpeechModel(body.model);
const providerConfig = providerId ? getSpeechProvider(providerId) : null;
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
// Falls back to hardcoded registry lookup for backward compatibility.
let providerConfig = resolvedProvider;
let modelId = resolvedModel;
if (!providerConfig) {
const parsed = parseSpeechModel(body.model);
providerConfig = parsed.provider ? getSpeechProvider(parsed.provider) : null;
modelId = parsed.model;
}
if (!providerConfig) {
return errorResponse(
@@ -403,7 +415,7 @@ export async function handleAudioSpeech({ body, credentials }) {
const token =
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
if (providerConfig.authType !== "none" && !token) {
return errorResponse(401, `No credentials for speech provider: ${providerId}`);
return errorResponse(401, `No credentials for speech provider: ${providerConfig.id}`);
}
try {
+18 -4
View File
@@ -13,7 +13,11 @@ import { getCorsOrigin } from "../utils/cors.ts";
* - HuggingFace Inference: POST raw binary to /models/{model_id}
*/
import { getTranscriptionProvider, parseTranscriptionModel } from "../config/audioRegistry.ts";
import {
getTranscriptionProvider,
parseTranscriptionModel,
type AudioProvider,
} from "../config/audioRegistry.ts";
import { buildAuthHeaders } from "../config/registryUtils.ts";
import { errorResponse } from "../utils/error.ts";
@@ -235,9 +239,13 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok
export async function handleAudioTranscription({
formData,
credentials,
resolvedProvider = null,
resolvedModel = null,
}: {
formData: FormData;
credentials?: TranscriptionCredentials | null;
resolvedProvider?: AudioProvider | null;
resolvedModel?: string | null;
}): Promise<Response> {
const model = formData.get("model");
if (typeof model !== "string" || !model) {
@@ -250,8 +258,14 @@ export async function handleAudioTranscription({
}
const file = fileEntry as Blob & { name?: unknown };
const { provider: providerId, model: modelId } = parseTranscriptionModel(model);
const providerConfig = providerId ? getTranscriptionProvider(providerId) : null;
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
let providerConfig = resolvedProvider;
let modelId = resolvedModel;
if (!providerConfig) {
const parsed = parseTranscriptionModel(model);
providerConfig = parsed.provider ? getTranscriptionProvider(parsed.provider) : null;
modelId = parsed.model;
}
if (!providerConfig) {
return errorResponse(
@@ -264,7 +278,7 @@ export async function handleAudioTranscription({
const token =
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
if (providerConfig.authType !== "none" && !token) {
return errorResponse(401, `No credentials for transcription provider: ${providerId}`);
return errorResponse(401, `No credentials for transcription provider: ${providerConfig.id}`);
}
// Route to provider-specific handler
+39 -1
View File
@@ -13,6 +13,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.ts";
import { createRequestLogger } from "../utils/requestLogger.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
import { HTTP_STATUS } from "../config/constants.ts";
import { handleBypassRequest } from "../utils/bypassHandler.ts";
@@ -53,7 +54,9 @@ export function shouldUseNativeCodexPassthrough({
}): boolean {
if (provider !== "codex") return false;
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
return String(endpointPath || "").toLowerCase().endsWith("/responses");
return String(endpointPath || "")
.toLowerCase()
.endsWith("/responses");
}
/**
@@ -182,10 +185,17 @@ export async function handleChatCore({
// Translate request (pass reqLogger for intermediate logging)
let translatedBody = body;
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
try {
if (nativeCodexPassthrough) {
translatedBody = { ...body, _nativeCodexPassthrough: true };
log?.debug?.("FORMAT", "native codex passthrough enabled");
} else if (isClaudePassthrough) {
// Claude-to-Claude passthrough: forward body completely untouched.
// No translation, no field stripping, no thinking normalization.
// We are just a gateway -- do not interfere with the request in any way.
translatedBody = { ...body };
log?.debug?.("FORMAT", "claude->claude passthrough -- forwarding untouched");
} else {
translatedBody = { ...body };
@@ -230,6 +240,19 @@ export async function handleChatCore({
});
}
// Strip empty text content blocks from messages.
// Anthropic API rejects {"type":"text","text":""} with 400 "text content blocks must be non-empty".
// Some clients (LiteLLM passthrough, @ai-sdk/anthropic) may forward these empty blocks as-is.
if (Array.isArray(translatedBody.messages)) {
for (const msg of translatedBody.messages) {
if (Array.isArray(msg.content)) {
msg.content = msg.content.filter((block: Record<string, unknown>) =>
block.type !== "text" || (typeof block.text === "string" && block.text.length > 0)
);
}
}
}
translatedBody = translateRequest(
sourceFormat,
targetFormat,
@@ -287,6 +310,21 @@ export async function handleChatCore({
// Update model in body
translatedBody.model = model;
// Strip unsupported parameters for reasoning models (o1, o3, etc.)
const unsupported = getUnsupportedParams(provider, model);
if (unsupported.length > 0) {
const stripped: string[] = [];
for (const param of unsupported) {
if (Object.hasOwn(translatedBody, param)) {
stripped.push(param);
delete translatedBody[param];
}
}
if (stripped.length > 0) {
log?.warn?.("PARAMS", `Stripped unsupported params for ${model}: ${stripped.join(", ")}`);
}
}
// Get executor for this provider
const executor = getExecutor(provider);
+47 -14
View File
@@ -13,18 +13,48 @@
* }
*/
import { getEmbeddingProvider, parseEmbeddingModel } from "../config/embeddingRegistry.ts";
import {
getEmbeddingProvider,
parseEmbeddingModel,
type EmbeddingProvider,
} from "../config/embeddingRegistry.ts";
import { saveCallLog } from "@/lib/usageDb";
/**
* Handle embedding request
* @param {object} options
* @param {object} options.body - Request body
* @param {object} options.credentials - Provider credentials { apiKey, accessToken }
* @param {object} options.log - Logger
* Handle embedding request.
* Supports both hardcoded cloud providers and dynamic local provider_nodes.
* When resolvedProvider is passed, uses it directly (injection pattern from route handler).
* Falls back to hardcoded registry lookup for backward compatibility.
*/
export async function handleEmbedding({ body, credentials, log }) {
const { provider, model } = parseEmbeddingModel(body.model);
export async function handleEmbedding({
body,
credentials,
log,
resolvedProvider = null,
resolvedModel = null,
}: {
body: Record<string, unknown>;
credentials: { apiKey?: string; accessToken?: string } | null;
log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
resolvedProvider?: EmbeddingProvider | null;
resolvedModel?: string | null;
}) {
// Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes).
let provider: string | null;
let model: string | null;
let providerConfig: EmbeddingProvider | null;
if (resolvedProvider) {
provider = resolvedProvider.id;
model = resolvedModel;
providerConfig = resolvedProvider;
} else {
const parsed = parseEmbeddingModel(body.model as string);
provider = parsed.provider;
model = parsed.model;
providerConfig = provider ? getEmbeddingProvider(provider) : null;
}
const startTime = Date.now();
// Summarized request body for call log (avoid storing large embedding input arrays)
@@ -42,7 +72,6 @@ export async function handleEmbedding({ body, credentials, log }) {
};
}
const providerConfig = getEmbeddingProvider(provider);
if (!providerConfig) {
return {
success: false,
@@ -66,11 +95,15 @@ export async function handleEmbedding({ body, credentials, log }) {
"Content-Type": "application/json",
};
const token = credentials.apiKey || credentials.accessToken;
if (providerConfig.authHeader === "bearer") {
headers["Authorization"] = `Bearer ${token}`;
} else if (providerConfig.authHeader === "x-api-key") {
headers["x-api-key"] = token;
// Skip credential injection for local providers (authType: "none")
const token =
providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken;
if (token) {
if (providerConfig.authHeader === "bearer") {
headers["Authorization"] = `Bearer ${token}`;
} else if (providerConfig.authHeader === "x-api-key") {
headers["x-api-key"] = token;
}
}
if (log) {
+40 -2
View File
@@ -11,6 +11,7 @@ import * as semaphore from "./rateLimitSemaphore.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
import { parseModel } from "./model.ts";
import { applyComboAgentMiddleware, injectModelTag } from "./comboAgentMiddleware.ts";
// Status codes that should mark semaphore + record circuit breaker failures
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
@@ -225,12 +226,49 @@ export async function handleComboChat({
const strategy = combo.strategy || "priority";
const models = combo.models || [];
// ── Combo Agent Middleware (#399 + #401) ────────────────────────────────
// Apply system_message override, tool_filter_regex, and extract pinned model
// from context caching tag. These are all opt-in per combo config.
const { body: agentBody, pinnedModel } = applyComboAgentMiddleware(
body,
combo,
"" // provider/model not yet known — resolved per-model in loop
);
body = agentBody;
if (pinnedModel) {
log.info("COMBO", `[#401] Context caching: pinned model=${pinnedModel}`);
}
// Wrap handleSingleModel to inject context caching tag on response (#401)
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) {
try {
const json = await res.clone().json();
const msgs = Array.isArray(json?.messages) ? json.messages : [];
if (msgs.length > 0) {
const tagged = injectModelTag(msgs, modelStr);
return new Response(JSON.stringify({ ...json, messages: tagged }), {
status: res.status,
headers: res.headers,
});
}
} catch {
/* non-JSON or stream — skip tagging */
}
}
return res;
}
: handleSingleModel;
// ─────────────────────────────────────────────────────────────────────────
// Route to round-robin handler if strategy matches
if (strategy === "round-robin") {
return handleRoundRobinCombo({
body,
combo,
handleSingleModel,
handleSingleModel: handleSingleModelWrapped,
isModelAvailable,
log,
settings,
@@ -348,7 +386,7 @@ export async function handleComboChat({
`Trying model ${i + 1}/${orderedModels.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
);
const result = await handleSingleModel(body, modelStr);
const result = await handleSingleModelWrapped(body, modelStr);
// Success — return response
if (result.ok) {
+169
View File
@@ -0,0 +1,169 @@
/**
* comboAgentMiddleware.ts — Combo Agent Features
*
* Implements the "combo as agent" features from issues #399 and #401:
*
* 1. **System Message Override** (#399): If the combo defines a `system_message`,
* it is injected as the first system message, replacing any existing system message.
*
* 2. **Tool Filter Regex** (#399): If the combo defines a `tool_filter_regex`,
* only tools whose name matches the pattern are forwarded to the provider.
*
* 3. **Context Caching Protection** (#401): If the combo enables
* `context_cache_protection`, the proxy:
* a. On response: injects `<omniModel>provider/model</omniModel>` tag into
* the first assistant message content string.
* b. On request: scans the message history for the tag, and if found,
* overrides the requested model with the pinned one.
*
* All features are opt-in per combo and backward compatible with existing setups.
*/
interface ComboConfig {
system_message?: string | null;
tool_filter_regex?: string | null;
context_cache_protection?: number | boolean;
[key: string]: unknown;
}
interface Message {
role?: string;
content?: unknown;
[key: string]: unknown;
}
// ── Context Caching Tag ─────────────────────────────────────────────────────
const CACHE_TAG_PATTERN = /<omniModel>([^<]+)<\/omniModel>/;
/**
* Inject the model tag into the last assistant message (or append a new one).
* Only modifies string content — does not touch array content to avoid breaking
* Claude/Gemini multi-part message formats.
*/
export function injectModelTag(messages: Message[], providerModel: string): Message[] {
// Remove any existing tag first to avoid duplication on context compaction
const cleaned = messages.map((msg) => {
if (msg.role === "assistant" && typeof msg.content === "string") {
return { ...msg, content: msg.content.replace(CACHE_TAG_PATTERN, "").trimEnd() };
}
return msg;
});
// Find last assistant message with string content
const lastAssistantIdx = cleaned.map((m) => m.role).lastIndexOf("assistant");
if (lastAssistantIdx === -1) return cleaned;
const msg = cleaned[lastAssistantIdx];
if (typeof msg.content !== "string") return cleaned;
const tagged = [...cleaned];
tagged[lastAssistantIdx] = {
...msg,
content: `${msg.content}\n<omniModel>${providerModel}</omniModel>`,
};
return tagged;
}
/**
* Scan message history for the model tag injected by a previous response.
* Returns the pinned "provider/model" string, or null if not found.
*/
export function extractPinnedModel(messages: Message[]): string | null {
// Scan from newest to oldest for efficiency
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant" && typeof msg.content === "string") {
const match = CACHE_TAG_PATTERN.exec(msg.content);
if (match) return match[1];
}
}
return null;
}
// ── System Message Override ──────────────────────────────────────────────────
/**
* Replace or inject a system message at the beginning of the messages array.
* Existing system messages are removed if a combo override is set.
*/
export function applySystemMessageOverride(messages: Message[], systemMessage: string): Message[] {
// Remove all existing system messages
const filtered = messages.filter((m) => m.role !== "system");
// Inject combo system message at start
return [{ role: "system", content: systemMessage }, ...filtered];
}
// ── Tool Filter Regex ────────────────────────────────────────────────────────
/**
* Filter the tools array, keeping only tools whose name matches the regex.
* Returns the original array unchanged if pattern is null/empty.
*/
export function applyToolFilter(
tools: unknown[] | undefined,
pattern: string | null | undefined
): unknown[] | undefined {
if (!tools || !pattern) return tools;
let regex: RegExp;
try {
regex = new RegExp(pattern);
} catch {
// Invalid regex — return tools unchanged rather than crashing
console.warn(`[ComboAgent] Invalid tool_filter_regex: "${pattern}"`);
return tools;
}
return tools.filter((tool) => {
const t = tool as Record<string, unknown>;
// Support both OpenAI format ({ function: { name } }) and Anthropic ({ name })
const name = (t.function as Record<string, unknown> | undefined)?.name ?? t.name ?? "";
return regex.test(String(name));
});
}
// ── Main Middleware ──────────────────────────────────────────────────────────
/**
* Apply all combo agent features to the request body.
* Safe to call with null/undefined comboConfig — returns body unchanged.
*/
export function applyComboAgentMiddleware(
body: Record<string, unknown>,
comboConfig: ComboConfig | null | undefined,
providerModel: string // "provider/model" string for context caching
): { body: Record<string, unknown>; pinnedModel: string | null } {
if (!comboConfig) return { body, pinnedModel: null };
let messages: Message[] = Array.isArray(body.messages) ? [...body.messages] : [];
let pinnedModel: string | null = null;
// 1. Context caching: check for pinned model in history
if (comboConfig.context_cache_protection) {
pinnedModel = extractPinnedModel(messages);
if (pinnedModel) {
// Model is pinned — caller should override model selection
}
}
// 2. System message override
if (comboConfig.system_message && comboConfig.system_message.trim()) {
messages = applySystemMessageOverride(messages, comboConfig.system_message);
}
// 3. Tool filter
const filteredTools = applyToolFilter(
body.tools as unknown[] | undefined,
comboConfig.tool_filter_regex
);
return {
body: {
...body,
messages,
...(filteredTools !== body.tools && { tools: filteredTools }),
},
pinnedModel,
};
}
@@ -91,6 +91,10 @@ export function filterToOpenAIFormat(body) {
delete body.tools;
}
// Strip Claude-specific fields that OpenAI-compatible providers reject
delete body.metadata;
delete body.anthropic_version;
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
body.tools = body.tools
+1 -1
View File
@@ -131,7 +131,7 @@ export function translateRequest(
}
// Final step: prepare request for Claude format endpoints
if (targetFormat === FORMATS.CLAUDE) {
if (targetFormat === FORMATS.CLAUDE && sourceFormat !== FORMATS.CLAUDE) {
result = prepareClaudeRequest(result, provider);
}
@@ -6,6 +6,7 @@
*/
import { register } from "../registry.ts";
import { FORMATS } from "../formats.ts";
import { generateToolCallId } from "../helpers/toolCallHelper.ts";
type JsonRecord = Record<string, unknown>;
@@ -120,6 +121,12 @@ export function openaiResponsesToOpenAIRequest(
}
if (itemType === "function_call") {
// Skip tool calls with empty names to avoid infinite placeholder_tool loops
const fnName = toString(item.name).trim();
if (!fnName) {
continue;
}
// Start or append assistant message with tool_calls
if (!currentAssistantMsg) {
currentAssistantMsg = {
@@ -136,7 +143,7 @@ export function openaiResponsesToOpenAIRequest(
id: toString(item.call_id),
type: "function",
function: {
name: toString(item.name),
name: fnName,
arguments: item.arguments,
},
});
@@ -201,6 +208,24 @@ export function openaiResponsesToOpenAIRequest(
});
}
// Filter orphaned tool results (no matching tool_call in any assistant message)
const allToolCallIds = new Set<string>();
for (const m of messages) {
const rec = toRecord(m);
if (Array.isArray(rec.tool_calls)) {
for (const tc of rec.tool_calls as { id?: string }[]) {
if (tc.id) allToolCallIds.add(String(tc.id));
}
}
}
result.messages = messages.filter((m) => {
const rec = toRecord(m);
if (rec.role === "tool" && rec.tool_call_id) {
return allToolCallIds.has(String(rec.tool_call_id));
}
return true;
});
// Cleanup Responses API specific fields
delete result.input;
delete result.instructions;
@@ -319,10 +344,15 @@ export function openaiToOpenAIResponsesRequest(
for (const toolCallValue of msg.tool_calls) {
const toolCall = toRecord(toolCallValue);
const fn = toRecord(toolCall.function);
// Skip tool calls with empty names to avoid infinite placeholder_tool loops
const fnName = toString(fn.name).trim();
if (!fnName) {
continue;
}
input.push({
type: "function_call",
call_id: toString(toolCall.id),
name: toString(fn.name),
call_id: toString(toolCall.id).trim() || generateToolCallId(),
name: fnName,
arguments: toString(fn.arguments, "{}"),
});
}
@@ -339,6 +369,22 @@ export function openaiToOpenAIResponsesRequest(
}
}
// Filter orphaned function_call_output items (no matching function_call)
// This happens when Claude Code compaction removes messages but leaves tool results
const knownCallIds = new Set(
input
.filter(
(item: { type?: string; call_id?: string }) => item.type === "function_call" && item.call_id
)
.map((item: { type?: string; call_id?: string }) => item.call_id)
);
result.input = input.filter((item: { type?: string; call_id?: string }) => {
if (item.type === "function_call_output" && item.call_id) {
return knownCallIds.has(item.call_id);
}
return true;
});
// If no system message, keep empty instructions
if (!hasSystemMessage) {
result.instructions = "";
@@ -123,6 +123,43 @@ export function openaiToClaudeRequest(model, body, stream) {
flushCurrentMessage();
// Remove assistant messages with empty content (can happen when all tool_use blocks were skipped)
result.messages = result.messages.filter((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content) && msg.content.length === 0) {
return false;
}
return true;
});
// Filter orphaned tool_result blocks whose tool_use_id has no matching tool_use
const allToolUseIds = new Set<string>();
for (const msg of result.messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.id) {
allToolUseIds.add(String(block.id));
}
}
}
}
for (const msg of result.messages) {
if (msg.role === "user" && Array.isArray(msg.content)) {
msg.content = msg.content.filter((block) => {
if (block.type === "tool_result" && block.tool_use_id) {
return allToolUseIds.has(String(block.tool_use_id));
}
return true;
});
}
}
// Remove user messages that became empty after orphan filtering
result.messages = result.messages.filter((msg) => {
if (msg.role === "user" && Array.isArray(msg.content) && msg.content.length === 0) {
return false;
}
return true;
});
// Add cache_control to last assistant message
for (let i = result.messages.length - 1; i >= 0; i--) {
const message = result.messages[i];
+29 -2
View File
@@ -184,6 +184,17 @@ export function createSSEStream(options: StreamOptions = {}) {
typeof parsed.type === "string" &&
parsed.type.startsWith("response.");
// Detect Claude SSE payloads. Includes "ping" and "error" to ensure
// they bypass the Chat Completions sanitization path which would
// incorrectly process or drop them.
const isClaudeSSE =
parsed.type &&
typeof parsed.type === "string" &&
(parsed.type.startsWith("message") ||
parsed.type.startsWith("content_block") ||
parsed.type === "ping" ||
parsed.type === "error");
if (isResponsesSSE) {
// Responses SSE: only extract usage, forward payload as-is
const extracted = extractUsage(parsed);
@@ -194,6 +205,22 @@ export function createSSEStream(options: StreamOptions = {}) {
if (parsed.delta && typeof parsed.delta === "string") {
totalContentLength += parsed.delta.length;
}
} else if (isClaudeSSE) {
// Claude SSE: extract usage, track content, forward as-is
const extracted = extractUsage(parsed);
if (extracted) {
// Non-destructive merge: never overwrite a positive value with 0
// message_start carries input_tokens, message_delta carries output_tokens
if (!usage) usage = {};
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
if (extracted.completion_tokens > 0) usage.completion_tokens = extracted.completion_tokens;
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
if (extracted.cache_read_input_tokens) usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
if (extracted.cache_creation_input_tokens) usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
}
// Track content length from Claude format
if (parsed.delta?.text) totalContentLength += parsed.delta.text.length;
if (parsed.delta?.thinking) totalContentLength += parsed.delta.thinking.length;
} else {
// Chat Completions: full sanitization pipeline
parsed = sanitizeStreamingChunk(parsed);
@@ -372,9 +399,9 @@ export function createSSEStream(options: StreamOptions = {}) {
controller.enqueue(encoder.encode(output));
}
// Estimate usage if provider didn't return valid usage (PASSTHROUGH is always OpenAI format)
// Estimate usage if provider didn't return valid usage
if (!hasValidUsage(usage) && totalContentLength > 0) {
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
}
if (hasValidUsage(usage)) {
+639 -435
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.6.2",
"version": "2.6.8",
"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": {
@@ -90,7 +90,7 @@
"express": "^5.2.1",
"fetch-socks": "^1.3.2",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^7.0.6",
"https-proxy-agent": "^8.0.0",
"jose": "^6.1.3",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

+56
View File
@@ -142,6 +142,62 @@ if (sanitisedCount > 0) {
console.log(" ️ No hardcoded paths found to sanitise");
}
// ── Step 5.6: Strip Turbopack hashed externals from compiled chunks ─────────
// Even when Turbopack is disabled at build time, some instrumentation chunks
// may still emit require('package-<16hexchars>') instead of require('package').
// These hashed names don't exist in node_modules and cause MODULE_NOT_FOUND at
// runtime. We strip the hex suffix from all .js files in app/.next/server/
// to ensure all require() calls use the real package names.
{
const serverOutput = join(APP_DIR, ".next", "server");
const HASH_RE = /(['"\\])([a-z@][a-z0-9@./_-]+-[0-9a-f]{16})\1/g;
let patchedFiles = 0;
let patchedMatches = 0;
const walkDir = (dir) => {
let entries = [];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry);
try {
const st = statSync(full);
if (st.isDirectory()) {
walkDir(full);
continue;
}
if (!entry.endsWith(".js")) continue;
const src = readFileSync(full, "utf8");
let count = 0;
const patched = src.replace(HASH_RE, (_, q, name) => {
const base = name.replace(/-[0-9a-f]{16}$/, "");
count++;
return `${q}${base}${q}`;
});
if (count > 0) {
writeFileSync(full, patched);
patchedFiles++;
patchedMatches += count;
}
} catch {
/* skip unreadable files */
}
}
};
if (existsSync(serverOutput)) {
walkDir(serverOutput);
if (patchedMatches > 0) {
console.log(
` 🔧 Hash-strip: patched ${patchedMatches} hashed require() in ${patchedFiles} server chunk file(s)`
);
} else {
console.log(" ✅ Hash-strip: no hashed externals found in compiled chunks.");
}
}
}
// ── Step 6: Copy static assets ─────────────────────────────
const staticSrc = join(ROOT, ".next", "static");
const staticDest = join(APP_DIR, ".next", "static");
@@ -81,29 +81,36 @@ const PROVIDER_MODELS: Record<
{ id: "openai/dall-e-2", name: "DALL-E 2" },
],
},
{ id: "xai", name: "xAI (Grok)", models: [{ id: "xai/grok-2-image", name: "Grok 2 Image" }] },
{
id: "xai",
name: "xAI (Grok)",
models: [{ id: "xai/grok-2-image-1212", name: "Grok 2 Image" }],
},
{
id: "together",
name: "Together AI",
models: [
{ id: "together/stable-diffusion-xl", name: "SDXL" },
{ id: "together/FLUX.1-schnell-Free", name: "FLUX.1 Schnell" },
{ id: "together/stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL" },
{ id: "together/black-forest-labs/FLUX.1-schnell-Free", name: "FLUX.1 Schnell" },
],
},
{
id: "fireworks",
name: "Fireworks AI",
models: [
{ id: "fireworks/stable-diffusion-xl-1024-v1-0", name: "SDXL 1024" },
{ id: "fireworks/flux-1-dev-fp8", name: "FLUX.1 Dev" },
{
id: "fireworks/accounts/fireworks/models/stable-diffusion-xl-1024-v1-0",
name: "SDXL 1024",
},
{ id: "fireworks/accounts/fireworks/models/flux-1-dev-fp8", name: "FLUX.1 Dev" },
],
},
{
id: "nebius",
name: "Nebius AI",
models: [
{ id: "nebius/flux-dev", name: "FLUX Dev" },
{ id: "nebius/sdxl", name: "SDXL" },
{ id: "nebius/black-forest-labs/flux-dev", name: "FLUX Dev" },
{ id: "nebius/black-forest-labs/flux-schnell", name: "FLUX Schnell" },
],
},
{
@@ -117,7 +124,10 @@ const PROVIDER_MODELS: Record<
{
id: "nanobanana",
name: "NanoBanana",
models: [{ id: "nanobanana/flux-schnell", name: "FLUX Schnell" }],
models: [
{ id: "nanobanana/nanobanana-flash", name: "NanoBanana Flash" },
{ id: "nanobanana/nanobanana-pro", name: "NanoBanana Pro" },
],
},
{
id: "sdwebui",
@@ -0,0 +1,614 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Modal } from "@/shared/components";
type ProxyItem = {
id: string;
name: string;
type: string;
host: string;
port: number;
region?: string | null;
notes?: string | null;
status?: string;
};
type UsageInfo = {
count: number;
assignments: Array<{ scope: string; scopeId: string | null }>;
};
type HealthInfo = {
proxyId: string;
totalRequests: number;
successRate: number | null;
avgLatencyMs: number | null;
lastSeenAt: string | null;
};
const EMPTY_FORM = {
id: "",
name: "",
type: "http",
host: "",
port: "8080",
username: "",
password: "",
region: "",
notes: "",
status: "active",
};
export default function ProxyRegistryManager() {
const [items, setItems] = useState<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [usageById, setUsageById] = useState<Record<string, UsageInfo>>({});
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
const [migrating, setMigrating] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkScope, setBulkScope] = useState("provider");
const [bulkScopeIds, setBulkScopeIds] = useState("");
const [bulkProxyId, setBulkProxyId] = useState("");
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
try {
const res = await fetch("/api/settings/proxies/health?hours=24");
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const entries = Array.isArray(data?.items) ? data.items : [];
const mapped = Object.fromEntries(
entries.map((entry: HealthInfo) => [entry.proxyId, entry])
) as Record<string, HealthInfo>;
setHealthById(mapped);
} catch {
// ignore health loading errors in UI
}
}, []);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/settings/proxies");
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to load proxy registry");
setItems([]);
return;
}
setItems(Array.isArray(data?.items) ? data.items : []);
void loadHealth();
} catch (e: any) {
setError(e?.message || "Failed to load proxy registry");
setItems([]);
} finally {
setLoading(false);
}
}, [loadHealth]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (items.length > 0 && !bulkProxyId) {
setBulkProxyId(items[0].id);
}
}, [items, bulkProxyId]);
const openCreate = () => {
setForm(EMPTY_FORM);
setModalOpen(true);
};
const openEdit = (item: ProxyItem) => {
setForm({
id: item.id,
name: item.name || "",
type: item.type || "http",
host: item.host || "",
port: String(item.port || 8080),
username: "",
password: "",
region: item.region || "",
notes: item.notes || "",
status: item.status || "active",
});
setModalOpen(true);
};
const loadUsage = async (proxyId: string) => {
try {
const res = await fetch(
`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
setUsageById((prev) => ({
...prev,
[proxyId]: {
count: Number(data?.count || 0),
assignments: Array.isArray(data?.assignments) ? data.assignments : [],
},
}));
} catch {
// ignore usage loading errors in UI
}
};
const handleSave = async () => {
if (!form.name.trim() || !form.host.trim()) {
setError("Name and host are required");
return;
}
setSaving(true);
setError(null);
const normalizedUsername = form.username.trim();
const normalizedPassword = form.password.trim();
const payload: Record<string, unknown> = {
...(editingId ? { id: editingId } : {}),
name: form.name.trim(),
type: form.type,
host: form.host.trim(),
port: Number(form.port || 8080),
region: form.region.trim() || null,
notes: form.notes.trim() || null,
status: form.status,
};
if (!editingId || normalizedUsername.length > 0) {
payload.username = normalizedUsername;
}
if (!editingId || normalizedPassword.length > 0) {
payload.password = normalizedPassword;
}
try {
const res = await fetch("/api/settings/proxies", {
method: editingId ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to save proxy");
return;
}
setModalOpen(false);
setForm(EMPTY_FORM);
await load();
} catch (e: any) {
setError(e?.message || "Failed to save proxy");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (res.ok) {
await load();
return;
}
const payload = await res.json().catch(() => ({}));
const inUse = res.status === 409;
if (inUse) {
const ok = window.confirm(
"This proxy is still assigned. Force delete and remove all assignments?"
);
if (!ok) return;
const forceRes = await fetch(`/api/settings/proxies?id=${encodeURIComponent(id)}&force=1`, {
method: "DELETE",
});
if (!forceRes.ok) {
const forcePayload = await forceRes.json().catch(() => ({}));
setError(forcePayload?.error?.message || "Failed to force delete proxy");
return;
}
await load();
return;
}
setError(payload?.error?.message || "Failed to delete proxy");
} catch (e: any) {
setError(e?.message || "Failed to delete proxy");
}
};
const handleMigrate = async () => {
setMigrating(true);
setError(null);
try {
const res = await fetch("/api/settings/proxies/migrate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: false }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to migrate legacy proxy config");
return;
}
await load();
} catch (e: any) {
setError(e?.message || "Failed to migrate legacy proxy config");
} finally {
setMigrating(false);
}
};
const handleBulkAssign = async () => {
setBulkSaving(true);
setError(null);
try {
const scopeIds =
bulkScope === "global"
? []
: bulkScopeIds
.split(/[\n,]/g)
.map((part) => part.trim())
.filter(Boolean);
const res = await fetch("/api/settings/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: bulkScope,
scopeIds,
proxyId: bulkProxyId || null,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error?.message || "Failed to run bulk assignment");
return;
}
setBulkOpen(false);
setBulkScopeIds("");
await load();
} catch (e: any) {
setError(e?.message || "Failed to run bulk assignment");
} finally {
setBulkSaving(false);
}
};
return (
<>
<Card className="p-6">
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-lg font-semibold">Proxy Registry</h3>
<p className="text-sm text-text-muted">Store reusable proxies and track assignments.</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
icon="upgrade"
onClick={handleMigrate}
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
Import Legacy
</Button>
<Button
size="sm"
variant="secondary"
icon="account_tree"
onClick={() => setBulkOpen(true)}
data-testid="proxy-registry-open-bulk"
>
Bulk Assign
</Button>
<Button
size="sm"
icon="add"
onClick={openCreate}
data-testid="proxy-registry-open-create"
>
Add Proxy
</Button>
</div>
</div>
{error && (
<div className="mb-3 px-3 py-2 rounded border border-red-500/30 bg-red-500/10 text-sm text-red-400">
{error}
</div>
)}
{loading ? (
<div className="text-sm text-text-muted">Loading proxies...</div>
) : items.length === 0 ? (
<div className="text-sm text-text-muted">No saved proxies yet.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-text-muted border-b border-border">
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">Endpoint</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Health (24h)</th>
<th className="py-2 pr-3">Usage</th>
<th className="py-2">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const usage = usageById[item.id];
const health = healthById[item.id];
return (
<tr key={item.id} className="border-b border-border/60">
<td className="py-2 pr-3">
<div className="font-medium text-text-main">{item.name}</div>
{item.region && (
<div className="text-xs text-text-muted">{item.region}</div>
)}
</td>
<td className="py-2 pr-3 font-mono text-xs text-text-muted">
{item.type}://{item.host}:{item.port}
</td>
<td className="py-2 pr-3">
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
{item.status || "active"}
</span>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{health ? (
<div className="flex flex-col gap-0.5">
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</div>
) : (
"-"
)}
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{usage ? `${usage.count} assignment(s)` : "-"}
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
icon="visibility"
onClick={() => void loadUsage(item.id)}
>
Usage
</Button>
<Button
size="sm"
variant="ghost"
icon="edit"
onClick={() => openEdit(item)}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
icon="delete"
onClick={() => void handleDelete(item.id)}
className="!text-red-400"
>
Delete
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
<Modal
isOpen={modalOpen}
onClose={() => {
if (!saving) setModalOpen(false);
}}
title={editingId ? "Edit Proxy" : "Create Proxy"}
maxWidth="lg"
>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted mb-1 block">Name</label>
<input
data-testid="proxy-registry-name-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Type</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.type}
onChange={(e) => setForm((prev) => ({ ...prev, type: e.target.value }))}
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Host</label>
<input
data-testid="proxy-registry-host-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.host}
onChange={(e) => setForm((prev) => ({ ...prev, host: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Port</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.port}
onChange={(e) => setForm((prev) => ({ ...prev, port: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Username</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.username}
placeholder={editingId ? "Leave blank to keep current username" : ""}
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Password</label>
<input
type="password"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.password}
placeholder={editingId ? "Leave blank to keep current password" : ""}
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Region</label>
<input
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.region}
onChange={(e) => setForm((prev) => ({ ...prev, region: e.target.value }))}
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Status</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Notes</label>
<textarea
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
rows={3}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button size="sm" icon="save" onClick={handleSave} loading={saving}>
Save
</Button>
</div>
</div>
</Modal>
<Modal
isOpen={bulkOpen}
onClose={() => {
if (!bulkSaving) setBulkOpen(false);
}}
title="Bulk Proxy Assignment"
maxWidth="lg"
>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted mb-1 block">Scope</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={bulkScope}
onChange={(e) => setBulkScope(e.target.value)}
>
<option value="global">global</option>
<option value="provider">provider</option>
<option value="account">account</option>
<option value="combo">combo</option>
</select>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Proxy</label>
<select
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
value={bulkProxyId}
onChange={(e) => setBulkProxyId(e.target.value)}
>
<option value="">(clear assignment)</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
</option>
))}
</select>
</div>
</div>
{bulkScope !== "global" && (
<div>
<label className="text-xs text-text-muted mb-1 block">
Scope IDs (comma or newline)
</label>
<textarea
data-testid="proxy-registry-bulk-scopeids-input"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
rows={5}
value={bulkScopeIds}
onChange={(e) => setBulkScopeIds(e.target.value)}
placeholder="provider-openai,provider-anthropic"
/>
</div>
)}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setBulkOpen(false)}>
Cancel
</Button>
<Button
size="sm"
icon="done_all"
onClick={handleBulkAssign}
loading={bulkSaving}
data-testid="proxy-registry-bulk-apply"
>
Apply
</Button>
</div>
</div>
</Modal>
</>
);
}
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ProxyConfigModal } from "@/shared/components";
import { useTranslations } from "next-intl";
import ProxyRegistryManager from "./ProxyRegistryManager";
export default function ProxyTab() {
const [proxyModalOpen, setProxyModalOpen] = useState(false);
@@ -41,39 +42,43 @@ export default function ProxyTab() {
return (
<>
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
vpn_lock
</span>
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
<div className="flex flex-col gap-6">
<Card className="p-0 overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
vpn_lock
</span>
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
</div>
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
<div className="flex items-center gap-3">
{globalProxy ? (
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded text-xs font-bold uppercase bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
{globalProxy.type}://{globalProxy.host}:{globalProxy.port}
</span>
</div>
) : (
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
)}
<Button
size="sm"
variant={globalProxy ? "secondary" : "primary"}
icon="settings"
onClick={() => {
loadGlobalProxy();
setProxyModalOpen(true);
}}
>
{globalProxy ? tc("edit") : t("configure")}
</Button>
</div>
</div>
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
<div className="flex items-center gap-3">
{globalProxy ? (
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded text-xs font-bold uppercase bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
{globalProxy.type}://{globalProxy.host}:{globalProxy.port}
</span>
</div>
) : (
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
)}
<Button
size="sm"
variant={globalProxy ? "secondary" : "primary"}
icon="settings"
onClick={() => {
loadGlobalProxy();
setProxyModalOpen(true);
}}
>
{globalProxy ? tc("edit") : t("configure")}
</Button>
</div>
</div>
</Card>
</Card>
<ProxyRegistryManager />
</div>
<ProxyConfigModal
isOpen={proxyModalOpen}
+3 -3
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import path from "path";
import fs from "fs";
import os from "os";
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+3 -3
View File
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server";
import { getDbInstance, SQLITE_FILE } from "@/lib/db/core";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import fs from "fs";
import path from "path";
import os from "os";
/**
* GET /api/db-backups/exportAll
+3 -3
View File
@@ -1,8 +1,8 @@
import { NextResponse } from "next/server";
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import path from "path";
import fs from "fs";
import os from "os";
import { getDbInstance, resetDbInstance, SQLITE_FILE } from "@/lib/db/core";
import { backupDbFile } from "@/lib/db/backup";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+50
View File
@@ -0,0 +1,50 @@
/**
* GET /api/logs/detail — List detailed request logs
* GET /api/logs/detail/:id — Get specific detailed log
* POST /api/logs/detail/toggle — Enable/disable detailed logging
*/
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
getRequestDetailLogs,
getRequestDetailLogCount,
isDetailedLoggingEnabled,
} from "@/lib/db/detailedLogs";
import { updateSettings } from "@/lib/db/settings";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
if (!isAuthenticated(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
const offset = Number(url.searchParams.get("offset") ?? 0);
const logs = getRequestDetailLogs(limit, offset);
const total = getRequestDetailLogCount();
const enabled = await isDetailedLoggingEnabled();
return NextResponse.json({ enabled, total, logs });
}
export async function POST(req: NextRequest) {
if (!isAuthenticated(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const enabled = body.enabled === true || body.enabled === "1";
await updateSettings({ detailed_logs_enabled: enabled });
return NextResponse.json({
success: true,
enabled,
message: enabled
? "Detailed logging enabled. Pipeline bodies will be captured for new requests."
: "Detailed logging disabled.",
});
}
+2
View File
@@ -18,6 +18,7 @@ export async function GET() {
const circuitBreakers = getAllCircuitBreakerStatuses();
const rateLimitStatus = getAllRateLimitStatus();
const lockouts = getAllModelLockouts();
const { getAllHealthStatuses } = await import("@/lib/localHealthCheck");
// System info
const system = {
@@ -46,6 +47,7 @@ export async function GET() {
timestamp: new Date().toISOString(),
system,
providerHealth,
localProviders: getAllHealthStatuses(),
rateLimitStatus,
lockouts,
setupComplete: settings?.setupComplete || false,
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { timingSafeEqual } from "crypto";
import {
getProvider,
generateAuthData,
@@ -255,6 +255,22 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
authPrefix: "Bearer ",
parseResponse: (data) => data.models || data.data || [],
},
synthetic: {
url: "https://api.synthetic.new/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"kilo-gateway": {
url: "https://api.kilo.ai/api/gateway/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
};
/**
+1
View File
@@ -20,6 +20,7 @@ const OAUTH_TEST_CONFIG = {
claude: {
// Claude doesn't have userinfo, we verify token exists and not expired
checkExpiry: true,
refreshable: true,
},
codex: {
// Codex OAuth tokens are ChatGPT session tokens, NOT standard OpenAI API keys.
@@ -0,0 +1,63 @@
import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } from "@/lib/localDb";
import { proxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const proxyId = searchParams.get("proxyId");
const scope = searchParams.get("scope");
const scopeId = searchParams.get("scopeId");
const resolveConnectionId = searchParams.get("resolveConnectionId");
if (resolveConnectionId) {
const resolved = await resolveProxyForConnection(resolveConnectionId);
return Response.json(resolved);
}
const assignments = await getProxyAssignments({
proxyId: proxyId || undefined,
scope: scope || undefined,
});
const filtered = scopeId
? assignments.filter((entry) => entry.scopeId === scopeId)
: assignments;
return Response.json({ items: filtered, total: filtered.length });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy assignments");
}
}
export async function PUT(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(proxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeId, proxyId } = validation.data;
const assigned = await assignProxyToScope(scope, scopeId || null, proxyId || null);
clearDispatcherCache();
return Response.json({ success: true, assignment: assigned });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update assignment");
}
}
@@ -0,0 +1,45 @@
import { bulkAssignProxyToScope } from "@/lib/localDb";
import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function PUT(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(bulkProxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeIds, proxyId } = validation.data;
const normalizedScope = scope === "key" ? "account" : scope;
const result = await bulkAssignProxyToScope(normalizedScope, scopeIds || [], proxyId || null);
clearDispatcherCache();
return Response.json({
success: true,
scope: normalizedScope,
requested: normalizedScope === "global" ? 1 : (scopeIds || []).length,
updated: result.updated,
failed: result.failed,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to run bulk assignment");
}
}
@@ -0,0 +1,13 @@
import { getProxyHealthStats } from "@/lib/localDb";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") || 24);
const items = await getProxyHealthStats({ hours });
return Response.json({ items, total: items.length, windowHours: hours });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy health stats");
}
}
@@ -0,0 +1,40 @@
import { migrateLegacyProxyConfigToRegistry } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { z } from "zod";
const migrateLegacyProxySchema = z.object({
force: z.boolean().optional(),
});
export async function POST(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(migrateLegacyProxySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const force = validation.data.force === true;
const result = await migrateLegacyProxyConfigToRegistry({ force });
return Response.json(result);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to migrate legacy proxy config");
}
}
+127
View File
@@ -0,0 +1,127 @@
import {
createProxy,
deleteProxyById,
getProxyById,
getProxyWhereUsed,
listProxies,
updateProxy,
} from "@/lib/localDb";
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const whereUsed = searchParams.get("whereUsed") === "1";
if (id && whereUsed) {
const usage = await getProxyWhereUsed(id);
return Response.json(usage);
}
if (id) {
const proxy = await getProxyById(id, { includeSecrets: false });
if (!proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(proxy);
}
const proxies = await listProxies({ includeSecrets: false });
return Response.json({ items: proxies, total: proxies.length });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");
}
}
export async function POST(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(createProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const created = await createProxy(validation.data);
return Response.json(created, { status: 201 });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to create proxy");
}
}
export async function PATCH(request: Request) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(updateProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { id, ...changes } = validation.data;
const updated = await updateProxy(id, changes);
if (!updated) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(updated);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy");
}
}
export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const force = searchParams.get("force") === "1";
if (!id) {
return createErrorResponse({
status: 400,
message: "id is required",
type: "invalid_request",
});
}
const deleted = await deleteProxyById(id, { force });
if (!deleted) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json({ success: true });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+20 -29
View File
@@ -8,6 +8,7 @@ import {
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { updateProxyConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import type { z } from "zod";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
@@ -135,11 +136,7 @@ export async function GET(request: Request) {
const config = await getProxyConfig();
return Response.json(config);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
return createErrorResponseFromUnknown(error, "Failed to load proxy config");
}
}
@@ -152,25 +149,22 @@ export async function PUT(request: Request) {
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(updateProxyConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json(
{
error: {
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const body = validation.data;
const normalizedBody = normalizeProxyPayload(body);
@@ -181,7 +175,7 @@ export async function PUT(request: Request) {
const routeError = toApiRouteError(error);
const status = Number(routeError.status) || 500;
const type = routeError.type || (status === 400 ? "invalid_request" : "server_error");
return Response.json({ error: { message: routeError.message, type } }, { status });
return createErrorResponse({ status, message: routeError.message, type });
}
}
@@ -196,20 +190,17 @@ export async function DELETE(request: Request) {
const id = searchParams.get("id");
if (!level) {
return Response.json(
{ error: { message: "level is required", type: "invalid_request" } },
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "level is required",
type: "invalid_request",
});
}
const updated = await deleteProxyForLevel(level, id);
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
const routeError = toApiRouteError(error);
return Response.json(
{ error: { message: routeError.message, type: "server_error" } },
{ status: 500 }
);
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+38 -61
View File
@@ -7,6 +7,7 @@ import {
} from "@omniroute/open-sse/utils/proxyDispatcher.ts";
import { testProxySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
@@ -38,61 +39,46 @@ export async function POST(request: Request) {
try {
rawBody = await request.json();
} catch {
return Response.json(
{ error: { message: "Invalid JSON body", type: "invalid_request" } },
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(testProxySchema, rawBody);
if (isValidationFailure(validation)) {
return Response.json(
{
error: {
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { proxy } = validation.data;
const proxyType = String(proxy.type || "http").toLowerCase();
if (proxyType === "socks5" && !isSocks5ProxyEnabled()) {
return Response.json(
{
error: {
message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)",
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)",
type: "invalid_request",
});
}
if (proxyType.startsWith("socks") && proxyType !== "socks5") {
return Response.json(
{
error: {
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
});
}
if (!getSupportedProxyTypes().has(proxyType)) {
return Response.json(
{
error: {
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: `proxy.type must be ${supportedTypesMessage()}`,
type: "invalid_request",
});
}
let proxyUrl: string;
@@ -108,27 +94,19 @@ export async function POST(request: Request) {
{ allowSocks5: isSocks5ProxyEnabled() }
);
if (!normalizedProxyUrl) {
return Response.json(
{
error: {
message: "Invalid proxy configuration",
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: "Invalid proxy configuration",
type: "invalid_request",
});
}
proxyUrl = normalizedProxyUrl;
} catch (proxyError) {
return Response.json(
{
error: {
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
type: "invalid_request",
},
},
{ status: 400 }
);
return createErrorResponse({
status: 400,
message: getErrorMessage(proxyError, "Invalid proxy configuration"),
type: "invalid_request",
});
}
const publicProxyUrl = proxyUrlForLogs(proxyUrl);
@@ -180,7 +158,6 @@ export async function POST(request: Request) {
clearTimeout(timeout);
}
} catch (error) {
const message = getErrorMessage(error, "Unexpected server error");
return Response.json({ error: { message, type: "server_error" } }, { status: 500 });
return createErrorResponseFromUnknown(error, "Unexpected server error");
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import bcrypt from "bcryptjs";
import { timingSafeEqual } from "node:crypto";
import { timingSafeEqual } from "crypto";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs";
import path from "path";
import fs from "fs";
import { resolveDataDir } from "@/lib/dataPaths";
/**
+115
View File
@@ -0,0 +1,115 @@
/**
* GET /api/system/version — Returns current version and latest available on npm
* POST /api/system/update — Triggers npm install -g omniroute@latest + pm2 restart
*
* Security: Requires admin authentication (same as other management routes).
* Safety: Update only runs if a newer version is available on npm.
*/
import { NextRequest, NextResponse } from "next/server";
import { execFile } from "child_process";
import { promisify } from "util";
import { isAuthenticated } from "@/shared/utils/apiAuth";
const execFileAsync = promisify(execFile);
export const dynamic = "force-dynamic";
/** Fetch latest version from npm registry (no install, just metadata) */
async function getLatestNpmVersion(): Promise<string | null> {
try {
const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], {
timeout: 10000,
});
const parsed = JSON.parse(stdout.trim());
return typeof parsed === "string" ? parsed : null;
} catch {
return null;
}
}
/** Current installed version from package.json */
function getCurrentVersion(): string {
try {
return require("../../../../../package.json").version as string;
} catch {
return "unknown";
}
}
/** Compare semver strings — returns true if a > b */
function isNewer(a: string | null, b: string): boolean {
if (!a) return false;
const parse = (v: string) => v.split(".").map(Number);
const [aMaj, aMin, aPat] = parse(a);
const [bMaj, bMin, bPat] = parse(b);
if (aMaj !== bMaj) return aMaj > bMaj;
if (aMin !== bMin) return aMin > bMin;
return aPat > bPat;
}
export async function GET(req: NextRequest) {
if (!isAuthenticated(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const current = getCurrentVersion();
const latest = await getLatestNpmVersion();
const updateAvailable = isNewer(latest, current);
return NextResponse.json({
current,
latest: latest ?? "unavailable",
updateAvailable,
channel: "npm",
});
}
export async function POST(req: NextRequest) {
if (!isAuthenticated(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const current = getCurrentVersion();
const latest = await getLatestNpmVersion();
if (!latest) {
return NextResponse.json(
{ success: false, error: "Could not reach npm registry" },
{ status: 503 }
);
}
if (!isNewer(latest, current)) {
return NextResponse.json({
success: false,
error: `Already on latest version (${current})`,
current,
latest,
});
}
// Run update in background — client gets immediate acknowledgment
const install = async () => {
try {
await execFileAsync("npm", ["install", "-g", `omniroute@${latest}`, "--ignore-scripts"], {
timeout: 300000, // 5 minutes
});
// Restart PM2 — non-fatal if pm2 not available (Docker/manual setups)
await execFileAsync("pm2", ["restart", "omniroute"]).catch(() => null);
console.log(`[AutoUpdate] Successfully updated to v${latest}`);
} catch (err) {
console.error(`[AutoUpdate] Update failed:`, err);
}
};
// Fire-and-forget
install();
return NextResponse.json({
success: true,
message: `Update to v${latest} started. Restarting in ~30 seconds.`,
from: current,
to: latest,
});
}
+41 -5
View File
@@ -6,10 +6,16 @@ import {
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
import {
parseSpeechModel,
getSpeechProvider,
buildDynamicAudioProvider,
type ProviderNodeRow,
} from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { getProviderNodes } from "@/lib/localDb";
import { v1AudioSpeechSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -55,7 +61,31 @@ export async function POST(request) {
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
const { provider } = parseSpeechModel(body.model);
// Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF)
let dynamicProviders: ReturnType<typeof buildDynamicAudioProvider>[] = [];
try {
const nodes = await getProviderNodes();
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: ProviderNodeRow) => {
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
try {
const hostname = new URL(n.baseUrl).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
})
.map((n) => buildDynamicAudioProvider(n, "/audio/speech"));
} catch {
// DB error — fall back to hardcoded providers only
}
const { provider, model: resolvedModel } = parseSpeechModel(body.model, dynamicProviders);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
@@ -63,8 +93,9 @@ export async function POST(request) {
);
}
// Check provider config for auth bypass
const providerConfig = getSpeechProvider(provider);
// Check provider config — hardcoded first, then dynamic
const providerConfig =
getSpeechProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null;
// Get credentials — skip for local providers (authType: "none")
let credentials = null;
@@ -75,7 +106,12 @@ export async function POST(request) {
}
}
const response = await handleAudioSpeech({ body, credentials });
const response = await handleAudioSpeech({
body,
credentials,
resolvedProvider: providerConfig,
resolvedModel,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
+44 -5
View File
@@ -6,10 +6,16 @@ import {
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseTranscriptionModel, getTranscriptionProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
import {
parseTranscriptionModel,
getTranscriptionProvider,
buildDynamicAudioProvider,
type ProviderNodeRow,
} from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { getProviderNodes } from "@/lib/localDb";
/**
* Handle CORS preflight
@@ -53,7 +59,34 @@ export async function POST(request) {
const policy = await enforceApiKeyPolicy(request, model as string);
if (policy.rejection) return policy.rejection;
const { provider } = parseTranscriptionModel(model);
// Load local provider_nodes for audio routing (only localhost — prevents auth bypass/SSRF)
let dynamicProviders: ReturnType<typeof buildDynamicAudioProvider>[] = [];
try {
const nodes = await getProviderNodes();
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: ProviderNodeRow) => {
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
try {
const hostname = new URL(n.baseUrl).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
})
.map((n) => buildDynamicAudioProvider(n, "/audio/transcriptions"));
} catch {
// DB error — fall back to hardcoded providers only
}
const { provider, model: resolvedModel } = parseTranscriptionModel(
model as string,
dynamicProviders
);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
@@ -61,8 +94,9 @@ export async function POST(request) {
);
}
// Check provider config for auth bypass
const providerConfig = getTranscriptionProvider(provider);
// Check provider config — hardcoded first, then dynamic
const providerConfig =
getTranscriptionProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null;
// Get credentials — skip for local providers (authType: "none")
let credentials = null;
@@ -73,7 +107,12 @@ export async function POST(request) {
}
}
const response = await handleAudioTranscription({ formData, credentials });
const response = await handleAudioTranscription({
formData,
credentials,
resolvedProvider: providerConfig,
resolvedModel,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
+65 -8
View File
@@ -9,6 +9,9 @@ import {
import {
parseEmbeddingModel,
getAllEmbeddingModels,
getEmbeddingProvider,
buildDynamicEmbeddingProvider,
type EmbeddingProviderNodeRow,
} from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -18,7 +21,7 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1EmbeddingsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getAllCustomModels } from "@/lib/localDb";
import { getAllCustomModels, getProviderNodes } from "@/lib/localDb";
/**
* Handle CORS preflight
@@ -110,8 +113,42 @@ export async function POST(request) {
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// Load local provider_nodes for embedding routing (only localhost — prevents auth bypass/SSRF)
let dynamicProviders: ReturnType<typeof buildDynamicEmbeddingProvider>[] = [];
try {
const nodes = await getProviderNodes();
dynamicProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: EmbeddingProviderNodeRow) => {
// provider_nodes apiType is "chat" or "responses" (not "embeddings") — local OpenAI-compatible
// backends expose /embeddings under the same base URL as chat, so we build the URL as baseUrl + /embeddings.
if (n.apiType !== "chat" && n.apiType !== "responses") return false;
try {
const hostname = new URL(n.baseUrl).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
})
.map((n) => {
try {
return buildDynamicEmbeddingProvider(n);
} catch (err) {
log.error("EMBED", `Skipping invalid provider_node ${n.prefix}: ${err}`);
return null;
}
})
.filter((p): p is NonNullable<typeof p> => p !== null);
} catch (err) {
log.error("EMBED", `Failed to load provider_nodes for embeddings: ${err}`);
}
// Parse model to get provider
const { provider } = parseEmbeddingModel(body.model);
const { provider, model: resolvedModel } = parseEmbeddingModel(body.model, dynamicProviders);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
@@ -119,19 +156,39 @@ export async function POST(request) {
);
}
// Get credentials for the embedding provider
const credentials = await getProviderCredentials(provider);
if (!credentials) {
// Resolve provider config — dynamic first (local override), then hardcoded
const providerConfig =
dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null;
if (!providerConfig) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for embedding provider: ${provider}`
`Unknown embedding provider: ${provider}. No matching hardcoded or local provider found.`
);
}
const result = await handleEmbedding({ body, credentials, log });
// Get credentials — skip for local providers (authType: "none")
let credentials = null;
if (providerConfig && providerConfig.authType !== "none") {
credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for embedding provider: ${provider}`
);
}
}
const result = await handleEmbedding({
body,
credentials,
log,
resolvedProvider: providerConfig,
resolvedModel,
});
if (result.success) {
await clearRecoveredProviderState(credentials);
if (credentials) await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify(result.data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -0,0 +1,82 @@
import { assignProxyToScope, getProxyAssignments, resolveProxyForConnection } from "@/lib/localDb";
import { proxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
function toPagination(searchParams: URLSearchParams) {
const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 100)));
const offset = Math.max(0, Number(searchParams.get("offset") || 0));
return { limit, offset };
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const proxyId = searchParams.get("proxy_id");
const scope = searchParams.get("scope");
const scopeId = searchParams.get("scope_id");
const resolveConnectionId = searchParams.get("resolve_connection_id");
if (resolveConnectionId) {
const resolved = await resolveProxyForConnection(resolveConnectionId);
return Response.json(resolved);
}
const all = await getProxyAssignments({
proxyId: proxyId || undefined,
scope: scope || undefined,
});
const filtered = scopeId ? all.filter((entry) => entry.scopeId === scopeId) : all;
const { limit, offset } = toPagination(searchParams);
const items = filtered.slice(offset, offset + limit);
return Response.json({
items,
page: { limit, offset, total: filtered.length },
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy assignments");
}
}
export async function PUT(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(proxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeId, proxyId } = validation.data;
const assignment = await assignProxyToScope(scope, scopeId || null, proxyId || null);
clearDispatcherCache();
return Response.json({ success: true, assignment });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy assignment");
}
}
@@ -0,0 +1,49 @@
import { bulkAssignProxyToScope } from "@/lib/localDb";
import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
export async function PUT(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(bulkProxyAssignmentSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { scope, scopeIds, proxyId } = validation.data;
const normalizedScope = scope === "key" ? "account" : scope;
const result = await bulkAssignProxyToScope(normalizedScope, scopeIds || [], proxyId || null);
clearDispatcherCache();
return Response.json({
success: true,
scope: normalizedScope,
requested: normalizedScope === "global" ? 1 : (scopeIds || []).length,
updated: result.updated,
failed: result.failed,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to run bulk assignment");
}
}
@@ -0,0 +1,17 @@
import { getProxyHealthStats } from "@/lib/localDb";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") || 24);
const items = await getProxyHealthStats({ hours });
return Response.json({ items, total: items.length, windowHours: hours });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxy health stats");
}
}
+151
View File
@@ -0,0 +1,151 @@
import {
createProxy,
deleteProxyById,
getProxyById,
getProxyWhereUsed,
listProxies,
updateProxy,
} from "@/lib/localDb";
import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
function toPagination(searchParams: URLSearchParams) {
const limit = Math.max(1, Math.min(200, Number(searchParams.get("limit") || 50)));
const offset = Math.max(0, Number(searchParams.get("offset") || 0));
return { limit, offset };
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const whereUsed = searchParams.get("where_used") === "1";
if (id && whereUsed) {
const usage = await getProxyWhereUsed(id);
return Response.json(usage);
}
if (id) {
const proxy = await getProxyById(id, { includeSecrets: false });
if (!proxy) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(proxy);
}
const { limit, offset } = toPagination(searchParams);
const items = await listProxies({ includeSecrets: false });
const paged = items.slice(offset, offset + limit);
return Response.json({
items: paged,
page: { limit, offset, total: items.length },
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");
}
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(createProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const created = await createProxy(validation.data);
return Response.json(created, { status: 201 });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to create proxy");
}
}
export async function PATCH(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(updateProxyRegistrySchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { id, ...changes } = validation.data;
const updated = await updateProxy(id, changes);
if (!updated) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json(updated);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to update proxy");
}
}
export async function DELETE(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const force = searchParams.get("force") === "1";
if (!id) {
return createErrorResponse({
status: 400,
message: "id is required",
type: "invalid_request",
});
}
const deleted = await deleteProxyById(id, { force });
if (!deleted) {
return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" });
}
return Response.json({ success: true });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to delete proxy");
}
}
+64
View File
@@ -14,6 +14,36 @@ const ENDPOINT_ROWS = [
{ path: "/models", method: "GET", noteKey: "endpointRewriteModelsNote" },
] as const;
const MANAGEMENT_ENDPOINT_ROWS = [
{ path: "/api/v1/management/proxies", method: "GET", noteKey: "mgmtProxiesListNote" },
{ path: "/api/v1/management/proxies", method: "POST", noteKey: "mgmtProxiesCreateNote" },
{
path: "/api/v1/management/proxies/health",
method: "GET",
noteKey: "mgmtProxiesHealthNote",
},
{
path: "/api/v1/management/proxies/bulk-assign",
method: "PUT",
noteKey: "mgmtProxiesBulkAssignNote",
},
{
path: "/api/v1/management/proxies/assignments",
method: "GET",
noteKey: "mgmtAssignmentsListNote",
},
{
path: "/api/v1/management/proxies/assignments",
method: "PUT",
noteKey: "mgmtAssignmentsUpdateNote",
},
{
path: "/api/settings/proxies/migrate",
method: "POST",
noteKey: "mgmtLegacyMigrationNote",
},
] as const;
const FEATURE_ITEMS = [
{ icon: "hub", titleKey: "featureRoutingTitle", textKey: "featureRoutingText" },
{ icon: "layers", titleKey: "featureCombosTitle", textKey: "featureCombosText" },
@@ -48,6 +78,7 @@ const TOC_ITEMS = [
{ href: "#client-compatibility", labelKey: "clientCompatibility" },
{ href: "#protocols", labelKey: "protocolsToc" },
{ href: "#api-reference", labelKey: "apiReference" },
{ href: "#management-api", labelKey: "managementApiReference" },
{ href: "#model-prefixes", labelKey: "modelPrefixes" },
{ href: "#troubleshooting", labelKey: "troubleshooting" },
] as const;
@@ -102,6 +133,10 @@ export default function DocsPage() {
...row,
note: t(row.noteKey),
}));
const managementEndpointRows = MANAGEMENT_ENDPOINT_ROWS.map((row) => ({
...row,
note: t(row.noteKey),
}));
const featureItems = FEATURE_ITEMS.map((item) => ({
...item,
@@ -490,6 +525,35 @@ POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
</div>
</section>
<section id="management-api" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("managementApiReference")}</h2>
<p className="text-sm text-text-muted mt-2">{t("managementApiDescription")}</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4">{t("method")}</th>
<th className="text-left py-2 pr-4">{t("path")}</th>
<th className="text-left py-2">{t("notes")}</th>
</tr>
</thead>
<tbody>
{managementEndpointRows.map((row) => (
<tr key={`${row.method}:${row.path}`} className="border-b border-border/60">
<td className="py-2 pr-4">
<code className="px-1.5 py-0.5 rounded bg-bg text-xs font-semibold">
{row.method}
</code>
</td>
<td className="py-2 pr-4 font-mono">{row.path}</td>
<td className="py-2 text-text-muted">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section id="troubleshooting" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("troubleshooting")}</h2>
<ul className="mt-4 list-disc list-inside text-sm text-text-muted space-y-2">
+9
View File
@@ -2345,6 +2345,8 @@
"clientCompatibility": "Client Compatibility",
"protocolsToc": "Protocols",
"apiReference": "API Reference",
"managementApiReference": "Management API Reference",
"managementApiDescription": "Automation endpoints for proxy registry, scope assignments, and legacy proxy migration.",
"method": "Method",
"path": "Path",
"notes": "Notes",
@@ -2440,6 +2442,13 @@
"endpointRewriteChatNote": "Rewrite helper for clients without /v1.",
"endpointRewriteResponsesNote": "Rewrite helper for Responses without /v1.",
"endpointRewriteModelsNote": "Rewrite helper for model discovery without /v1.",
"mgmtProxiesListNote": "List saved proxy registry items (supports pagination).",
"mgmtProxiesCreateNote": "Create a reusable proxy item in the registry.",
"mgmtProxiesHealthNote": "Get 24h/rolling health metrics per saved proxy from proxy logs.",
"mgmtProxiesBulkAssignNote": "Assign or clear one proxy across many scope IDs in one request.",
"mgmtAssignmentsListNote": "List proxy assignments by scope, scope_id, or proxy_id.",
"mgmtAssignmentsUpdateNote": "Assign or clear proxy for global/provider/account/combo scope.",
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"modelPrefixesDescriptionStart": "Use the provider prefix before the model name to route to a specific provider. Example:",
"modelPrefixesDescriptionEnd": "routes to GitHub Copilot.",
"provider": "Provider",
+9
View File
@@ -2310,6 +2310,8 @@
"clientCompatibility": "Kompatibilitas Klien",
"protocolsToc": "Protocols",
"apiReference": "Referensi API",
"managementApiReference": "Referensi API Manajemen",
"managementApiDescription": "Endpoint automasi untuk registry proxy, assignment scope, dan migrasi proxy lama.",
"method": "Metode",
"path": "Jalan",
"notes": "Catatan",
@@ -2405,6 +2407,13 @@
"endpointRewriteChatNote": "Tulis ulang pembantu untuk klien tanpa /v1.",
"endpointRewriteResponsesNote": "Tulis ulang pembantu untuk Respons tanpa /v1.",
"endpointRewriteModelsNote": "Tulis ulang pembantu untuk penemuan model tanpa /v1.",
"mgmtProxiesListNote": "Daftar item proxy registry tersimpan (mendukung pagination).",
"mgmtProxiesCreateNote": "Buat item proxy reusable di registry.",
"mgmtProxiesHealthNote": "Ambil metrik kesehatan proxy tersimpan (24 jam/window) dari proxy logs.",
"mgmtProxiesBulkAssignNote": "Set atau hapus satu proxy ke banyak scope ID dalam satu request.",
"mgmtAssignmentsListNote": "Daftar assignment proxy berdasarkan scope, scope_id, atau proxy_id.",
"mgmtAssignmentsUpdateNote": "Set atau hapus proxy untuk scope global/provider/account/combo.",
"mgmtLegacyMigrationNote": "Impor map proxyConfig lama ke assignment registry.",
"modelPrefixesDescriptionStart": "Gunakan awalan penyedia sebelum nama model untuk merutekan ke penyedia tertentu. Contoh:",
"modelPrefixesDescriptionEnd": "rute ke GitHub Copilot.",
"provider": "Penyedia",
+54
View File
@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto";
export type ApiErrorType = "invalid_request" | "not_found" | "conflict" | "server_error";
interface ApiErrorPayload {
status: number;
message: string;
type?: ApiErrorType;
details?: unknown;
}
export function createErrorResponse(payload: ApiErrorPayload): Response {
const requestId = randomUUID();
const resolvedType =
payload.type ||
(payload.status >= 500
? "server_error"
: payload.status === 404
? "not_found"
: payload.status === 409
? "conflict"
: "invalid_request");
return Response.json(
{
error: {
message: payload.message,
type: resolvedType,
details: payload.details,
},
requestId,
},
{ status: payload.status }
);
}
export function createErrorResponseFromUnknown(
error: unknown,
fallbackMessage = "Unexpected server error"
): Response {
const anyError = error as {
message?: string;
status?: number;
type?: ApiErrorType;
details?: unknown;
};
const status = Number(anyError?.status) || 500;
return createErrorResponse({
status,
message: typeof anyError?.message === "string" ? anyError.message : fallbackMessage,
type: anyError?.type,
details: anyError?.details,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { isAuthenticated, isAuthRequired } from "@/shared/utils/apiAuth";
import { createErrorResponse } from "@/lib/api/errorResponse";
export async function requireManagementAuth(request: Request): Promise<Response | null> {
if (!(await isAuthRequired())) {
return null;
}
if (await isAuthenticated(request)) {
return null;
}
return createErrorResponse({
status: 401,
message: "Authentication required",
type: "invalid_request",
});
}
+2 -2
View File
@@ -1,5 +1,5 @@
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import http from "http";
import type { IncomingMessage, ServerResponse } from "http";
import { getRuntimePorts } from "@/lib/runtime/ports";
const DEFAULT_PROXY_TIMEOUT_MS = 30_000;
+1 -1
View File
@@ -8,7 +8,7 @@
* @module lib/cacheLayer
*/
import crypto from "node:crypto";
import crypto from "crypto";
/**
* @typedef {Object} CacheEntry
+2 -2
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import os from "node:os";
import path from "path";
import os from "os";
export const APP_NAME = "omniroute";
+2 -2
View File
@@ -3,8 +3,8 @@
*/
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import path from "path";
import fs from "fs";
import {
getDbInstance,
resetDbInstance,
+2 -2
View File
@@ -5,8 +5,8 @@
*/
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import path from "path";
import fs from "fs";
import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths";
import { runMigrations } from "./migrationRunner";
+101
View File
@@ -0,0 +1,101 @@
/**
* Detailed Request Logs DB Layer (#378)
*
* Saves full request/response bodies at each pipeline stage.
* Ring-buffer of 500 entries enforced by SQL trigger in migration 006.
* Only active when settings.detailed_logs_enabled = "1".
*/
import { v4 as uuidv4 } from "uuid";
import { getDbInstance } from "./core";
import { getSettings } from "./settings";
export interface RequestDetailLog {
id?: string;
call_log_id?: string | null;
timestamp?: string;
client_request?: string | null;
translated_request?: string | null;
provider_response?: string | null;
client_response?: string | null;
provider?: string | null;
model?: string | null;
source_format?: string | null;
target_format?: string | null;
duration_ms?: number;
}
/** Returns true if detailed logging is enabled in settings */
export async function isDetailedLoggingEnabled(): Promise<boolean> {
try {
const settings = await getSettings();
const val = settings.detailed_logs_enabled;
return val === true || val === "1" || val === "true";
} catch {
return false;
}
}
/** Save a detailed log entry — caller must verify isDetailedLoggingEnabled() first */
export function saveRequestDetailLog(entry: RequestDetailLog): void {
const db = getDbInstance();
const id = entry.id ?? uuidv4();
const timestamp = entry.timestamp ?? new Date().toISOString();
// Trim large bodies to avoid excessive disk usage (max 64KB each)
const trim = (s: string | null | undefined, max = 65536): string | null => {
if (!s) return null;
return s.length > max ? s.slice(0, max) + "…[truncated]" : s;
};
db.prepare(
`
INSERT INTO request_detail_logs
(id, call_log_id, timestamp, client_request, translated_request,
provider_response, client_response, provider, model, source_format, target_format, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
).run(
id,
entry.call_log_id ?? null,
timestamp,
trim(entry.client_request),
trim(entry.translated_request),
trim(entry.provider_response),
trim(entry.client_response),
entry.provider ?? null,
entry.model ?? null,
entry.source_format ?? null,
entry.target_format ?? null,
entry.duration_ms ?? 0
);
}
/** Fetch detailed logs (latest first) */
export function getRequestDetailLogs(limit = 50, offset = 0): RequestDetailLog[] {
const db = getDbInstance();
return db
.prepare(
`
SELECT * FROM request_detail_logs
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`
)
.all(limit, offset) as RequestDetailLog[];
}
/** Get a single detailed log by ID */
export function getRequestDetailLogById(id: string): RequestDetailLog | null {
const db = getDbInstance();
return (db.prepare("SELECT * FROM request_detail_logs WHERE id = ?").get(id) ??
null) as RequestDetailLog | null;
}
/** Get total count of detailed logs */
export function getRequestDetailLogCount(): number {
const db = getDbInstance();
const row = db.prepare("SELECT COUNT(*) as cnt FROM request_detail_logs").get() as {
cnt: number;
};
return row?.cnt ?? 0;
}
+3 -3
View File
@@ -9,9 +9,9 @@
* All migrations run within a single transaction all-or-nothing per file.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import type Database from "better-sqlite3";
/**
@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS proxy_registry (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
region TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_proxy_registry_status ON proxy_registry(status);
CREATE INDEX IF NOT EXISTS idx_proxy_registry_host ON proxy_registry(host);
CREATE TABLE IF NOT EXISTS proxy_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy_id TEXT NOT NULL,
scope TEXT NOT NULL,
scope_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(scope, scope_id),
FOREIGN KEY (proxy_id) REFERENCES proxy_registry(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_proxy_assignments_proxy_id ON proxy_assignments(proxy_id);
CREATE INDEX IF NOT EXISTS idx_proxy_assignments_scope ON proxy_assignments(scope, scope_id);
@@ -0,0 +1,19 @@
-- 005_combo_agent_fields.sql
-- Safe migration for existing users: adds optional agent fields to combos.
-- Uses ADD COLUMN with DEFAULT NULL (SQLite compatible) — existing rows are untouched.
-- New fields are read as NULL by old code versions (backward compatible).
-- System prompt override: when set, injected as the first system message before
-- forwarding to the provider. Overrides any system message from the client.
ALTER TABLE combos ADD COLUMN system_message TEXT DEFAULT NULL;
-- Regex-based tool filter: when set, only tool calls whose "name" matches this
-- regex pattern are forwarded to the provider. Others are stripped silently.
-- Example: "^(gh_|create_file|web_fetch)" — allows only GitHub and web tools.
ALTER TABLE combos ADD COLUMN tool_filter_regex TEXT DEFAULT NULL;
-- Context caching protection: when 1, the proxy tags assistant responses with
-- <omniModel>provider/model</omniModel> and pins the model for the session.
ALTER TABLE combos ADD COLUMN context_cache_protection INTEGER DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_combos_cache_protection ON combos(context_cache_protection);
@@ -0,0 +1,42 @@
-- 006_detailed_request_logs.sql
-- Stores full request/response bodies at each pipeline stage for debugging.
-- Only populated when detailed_logs_enabled = 1 in settings (off by default).
-- Ring-buffer enforced via trigger: keeps only the last 500 entries.
-- Existing users are not impacted (table is new, feature is opt-in).
CREATE TABLE IF NOT EXISTS request_detail_logs (
id TEXT PRIMARY KEY,
call_log_id TEXT, -- FK to call_logs.id (optional, nullable)
timestamp TEXT NOT NULL,
-- The 4 pipeline stages (all nullable — only populated when available)
client_request TEXT, -- Raw body received from the client (JSON)
translated_request TEXT, -- Body after format translation (JSON)
provider_response TEXT, -- Raw body from the provider (JSON)
client_response TEXT, -- Final body sent to the client (JSON)
-- Metadata
provider TEXT,
model TEXT,
source_format TEXT,
target_format TEXT,
duration_ms INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_rdl_timestamp ON request_detail_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_rdl_call_log_id ON request_detail_logs(call_log_id);
-- Ring-buffer trigger: auto-delete oldest records beyond 500
CREATE TRIGGER IF NOT EXISTS trg_rdl_ring_buffer
AFTER INSERT ON request_detail_logs
BEGIN
DELETE FROM request_detail_logs
WHERE id IN (
SELECT id FROM request_detail_logs
ORDER BY timestamp ASC
LIMIT MAX(0, (SELECT COUNT(*) FROM request_detail_logs) - 500)
);
END;
-- Settings key for enabling/disabling detailed logs (default: disabled)
-- Inserted only if not already present (safe for existing installs)
INSERT OR IGNORE INTO key_value (namespace, key, value)
VALUES ('settings', 'detailed_logs_enabled', '0');
+1 -1
View File
@@ -9,7 +9,7 @@
* @module lib/db/prompts
*/
import crypto from "node:crypto";
import crypto from "crypto";
import { getDbInstance } from "./core";
interface StatementLike<TRow = unknown> {
+588
View File
@@ -0,0 +1,588 @@
import { randomUUID } from "node:crypto";
import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
type JsonRecord = Record<string, unknown>;
type ProxyScope = "global" | "provider" | "account" | "combo";
interface ProxyRegistryRecord {
id: string;
name: string;
type: string;
host: string;
port: number;
username: string;
password: string;
region: string | null;
notes: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
interface ProxyAssignmentRecord {
id: number;
proxyId: string;
scope: ProxyScope;
scopeId: string | null;
createdAt: string;
updatedAt: string;
}
interface ProxyPayload {
name: string;
type: string;
host: string;
port: number;
username?: string;
password?: string;
region?: string | null;
notes?: string | null;
status?: string;
}
interface LegacyProxyConfig {
global?: unknown;
providers?: Record<string, unknown>;
combos?: Record<string, unknown>;
keys?: Record<string, unknown>;
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" ? (value as JsonRecord) : {};
}
function mapProxyRow(row: unknown): ProxyRegistryRecord {
const r = toRecord(row);
return {
id: typeof r.id === "string" ? r.id : "",
name: typeof r.name === "string" ? r.name : "",
type: typeof r.type === "string" ? r.type : "http",
host: typeof r.host === "string" ? r.host : "",
port: Number(r.port) || 0,
username: typeof r.username === "string" ? r.username : "",
password: typeof r.password === "string" ? r.password : "",
region: typeof r.region === "string" ? r.region : null,
notes: typeof r.notes === "string" ? r.notes : null,
status: typeof r.status === "string" ? r.status : "active",
createdAt: typeof r.created_at === "string" ? r.created_at : "",
updatedAt: typeof r.updated_at === "string" ? r.updated_at : "",
};
}
function mapAssignmentRow(row: unknown): ProxyAssignmentRecord {
const r = toRecord(row);
const scope = (typeof r.scope === "string" ? r.scope : "global") as ProxyScope;
const rawScopeId = typeof r.scope_id === "string" ? r.scope_id : null;
return {
id: Number(r.id) || 0,
proxyId: typeof r.proxy_id === "string" ? r.proxy_id : "",
scope,
scopeId: scope === "global" && rawScopeId === "__global__" ? null : rawScopeId,
createdAt: typeof r.created_at === "string" ? r.created_at : "",
updatedAt: typeof r.updated_at === "string" ? r.updated_at : "",
};
}
function normalizeScope(scope: string): ProxyScope {
const value = String(scope || "").toLowerCase();
if (value === "key") return "account";
if (value === "provider") return "provider";
if (value === "account") return "account";
if (value === "combo") return "combo";
return "global";
}
function coerceProxyPayload(value: unknown, fallbackName: string): ProxyPayload | null {
if (!value) return null;
if (typeof value === "string") {
try {
const parsed = new URL(value);
return {
name: fallbackName,
type: parsed.protocol.replace(":", "") || "http",
host: parsed.hostname,
port: Number(parsed.port || (parsed.protocol === "https:" ? "443" : "8080")),
username: parsed.username ? decodeURIComponent(parsed.username) : "",
password: parsed.password ? decodeURIComponent(parsed.password) : "",
status: "active",
};
} catch {
return null;
}
}
if (typeof value !== "object" || Array.isArray(value)) return null;
const record = toRecord(value);
const host = typeof record.host === "string" ? record.host.trim() : "";
if (!host) return null;
const port = Number(record.port) || 8080;
return {
name: fallbackName,
type: typeof record.type === "string" ? record.type : "http",
host,
port,
username: typeof record.username === "string" ? record.username : "",
password: typeof record.password === "string" ? record.password : "",
status: "active",
};
}
export function redactProxySecrets(proxy: ProxyRegistryRecord): ProxyRegistryRecord {
return {
...proxy,
username: proxy.username ? "***" : "",
password: proxy.password ? "***" : "",
};
}
export async function listProxies(options?: { includeSecrets?: boolean }) {
const includeSecrets = options?.includeSecrets === true;
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, name, type, host, port, username, password, region, notes, status, created_at, updated_at FROM proxy_registry ORDER BY datetime(updated_at) DESC, name ASC"
)
.all();
const proxies = rows.map(mapProxyRow);
return includeSecrets ? proxies : proxies.map(redactProxySecrets);
}
export async function getProxyById(id: string, options?: { includeSecrets?: boolean }) {
const includeSecrets = options?.includeSecrets === true;
const db = getDbInstance();
const row = db
.prepare(
"SELECT id, name, type, host, port, username, password, region, notes, status, created_at, updated_at FROM proxy_registry WHERE id = ?"
)
.get(id);
if (!row) return null;
const proxy = mapProxyRow(row);
return includeSecrets ? proxy : redactProxySecrets(proxy);
}
export async function createProxy(payload: ProxyPayload) {
const db = getDbInstance();
const id = randomUUID();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO proxy_registry
(id, name, type, host, port, username, password, region, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
payload.name,
payload.type,
payload.host,
Number(payload.port),
payload.username || "",
payload.password || "",
payload.region || null,
payload.notes || null,
payload.status || "active",
now,
now
);
backupDbFile("pre-write");
return getProxyById(id, { includeSecrets: false });
}
export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
const db = getDbInstance();
const existing = await getProxyById(id, { includeSecrets: true });
if (!existing) return null;
const incomingUsername =
typeof payload.username === "string" ? payload.username.trim() : undefined;
const incomingPassword =
typeof payload.password === "string" ? payload.password.trim() : undefined;
const merged = {
...existing,
...payload,
// Preserve stored credentials unless caller explicitly sends non-empty replacements.
username:
incomingUsername === undefined || incomingUsername.length === 0
? existing.username
: incomingUsername,
password:
incomingPassword === undefined || incomingPassword.length === 0
? existing.password
: incomingPassword,
updatedAt: new Date().toISOString(),
};
db.prepare(
`UPDATE proxy_registry
SET name = ?, type = ?, host = ?, port = ?, username = ?, password = ?, region = ?, notes = ?, status = ?, updated_at = ?
WHERE id = ?`
).run(
merged.name,
merged.type,
merged.host,
Number(merged.port),
merged.username || "",
merged.password || "",
merged.region || null,
merged.notes || null,
merged.status || "active",
merged.updatedAt,
id
);
backupDbFile("pre-write");
return getProxyById(id, { includeSecrets: false });
}
export async function getProxyAssignments(filters?: { proxyId?: string; scope?: string }) {
const db = getDbInstance();
if (filters?.proxyId) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE proxy_id = ? ORDER BY scope, scope_id"
)
.all(filters.proxyId)
.map(mapAssignmentRow);
}
if (filters?.scope) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE scope = ? ORDER BY scope_id"
)
.all(normalizeScope(filters.scope))
.map(mapAssignmentRow);
}
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments ORDER BY scope, scope_id"
)
.all()
.map(mapAssignmentRow);
}
export async function getProxyWhereUsed(proxyId: string) {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE proxy_id = ? ORDER BY scope, scope_id"
)
.all(proxyId)
.map(mapAssignmentRow);
return {
count: rows.length,
assignments: rows,
};
}
export async function assignProxyToScope(
scope: string,
scopeId: string | null,
proxyId: string | null
): Promise<ProxyAssignmentRecord | null> {
const normalizedScope = normalizeScope(scope);
const normalizedScopeId = normalizedScope === "global" ? "__global__" : scopeId;
const db = getDbInstance();
if (!proxyId) {
db.prepare("DELETE FROM proxy_assignments WHERE scope = ? AND scope_id IS ?").run(
normalizedScope,
normalizedScopeId
);
backupDbFile("pre-write");
return null;
}
const proxy = await getProxyById(proxyId, { includeSecrets: true });
if (!proxy) {
const err = new Error(`Proxy not found: ${proxyId}`) as Error & { status?: number };
err.status = 404;
throw err;
}
const now = new Date().toISOString();
db.prepare(
`INSERT INTO proxy_assignments (proxy_id, scope, scope_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scope, scope_id)
DO UPDATE SET proxy_id = excluded.proxy_id, updated_at = excluded.updated_at`
).run(proxyId, normalizedScope, normalizedScopeId, now, now);
backupDbFile("pre-write");
const row = db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE scope = ? AND scope_id IS ?"
)
.get(normalizedScope, normalizedScopeId);
return row ? mapAssignmentRow(row) : null;
}
export async function deleteProxyById(id: string, options?: { force?: boolean }) {
const force = options?.force === true;
const db = getDbInstance();
const usage = await getProxyWhereUsed(id);
if (!force && usage.count > 0) {
const err = new Error(
"Proxy is still assigned. Remove assignments first or use force=true"
) as Error & {
status?: number;
code?: string;
};
err.status = 409;
err.code = "proxy_in_use";
throw err;
}
if (force && usage.count > 0) {
db.prepare("DELETE FROM proxy_assignments WHERE proxy_id = ?").run(id);
}
const result = db.prepare("DELETE FROM proxy_registry WHERE id = ?").run(id);
backupDbFile("pre-write");
return result.changes > 0;
}
export async function resolveProxyForConnectionFromRegistry(connectionId: string) {
const db = getDbInstance();
const accountAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1"
)
.get(connectionId);
if (accountAssignment) {
const record = toRecord(accountAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "account",
levelId: connectionId,
source: "registry",
};
}
const connection = db
.prepare("SELECT provider FROM provider_connections WHERE id = ?")
.get(connectionId) as { provider?: string } | undefined;
if (connection?.provider) {
const providerAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
)
.get(connection.provider);
if (providerAssignment) {
const record = toRecord(providerAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "provider",
levelId: connection.provider,
source: "registry",
};
}
}
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
)
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "global",
levelId: null,
source: "registry",
};
}
return null;
}
export async function migrateLegacyProxyConfigToRegistry(options?: { force?: boolean }) {
const force = options?.force === true;
const db = getDbInstance();
const existingCountRow = db.prepare("SELECT COUNT(*) AS cnt FROM proxy_registry").get() as
| { cnt?: number }
| undefined;
const existingCount = Number(existingCountRow?.cnt || 0);
if (!force && existingCount > 0) {
return { migrated: 0, skipped: true, reason: "registry_not_empty" as const };
}
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'proxyConfig'")
.all() as Array<{ key?: string; value?: string }>;
const raw: LegacyProxyConfig = {};
for (const row of rows) {
if (!row?.key || typeof row.value !== "string") continue;
try {
raw[row.key as keyof LegacyProxyConfig] = JSON.parse(row.value);
} catch {
// ignore malformed legacy entry
}
}
let migrated = 0;
if (raw.global) {
const payload = coerceProxyPayload(raw.global, "Legacy Global Proxy");
if (payload) {
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("global", null, created.id);
migrated++;
}
}
}
for (const [providerId, proxyValue] of Object.entries(raw.providers || {})) {
const payload = coerceProxyPayload(proxyValue, `Legacy Provider Proxy (${providerId})`);
if (!payload) continue;
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("provider", providerId, created.id);
migrated++;
}
}
for (const [comboId, proxyValue] of Object.entries(raw.combos || {})) {
const payload = coerceProxyPayload(proxyValue, `Legacy Combo Proxy (${comboId})`);
if (!payload) continue;
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("combo", comboId, created.id);
migrated++;
}
}
for (const [connectionId, proxyValue] of Object.entries(raw.keys || {})) {
const payload = coerceProxyPayload(proxyValue, `Legacy Account Proxy (${connectionId})`);
if (!payload) continue;
const created = await createProxy(payload);
if (created?.id) {
await assignProxyToScope("account", connectionId, created.id);
migrated++;
}
}
return { migrated, skipped: false as const };
}
export async function getProxyHealthStats(options?: { hours?: number }) {
const db = getDbInstance();
const hours = Math.max(1, Math.min(24 * 30, Number(options?.hours || 24)));
const sinceIso = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
const rows = db
.prepare(
`SELECT
p.id as proxy_id,
p.name as proxy_name,
p.type as proxy_type,
p.host as proxy_host,
p.port as proxy_port,
COUNT(l.id) as total_requests,
SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN l.status = 'error' THEN 1 ELSE 0 END) as error_count,
SUM(CASE WHEN l.status = 'timeout' THEN 1 ELSE 0 END) as timeout_count,
AVG(CASE WHEN l.latency_ms IS NOT NULL THEN l.latency_ms END) as avg_latency_ms,
MAX(l.timestamp) as last_seen_at
FROM proxy_registry p
LEFT JOIN proxy_logs l
ON l.proxy_host = p.host
AND l.proxy_type = p.type
AND l.proxy_port = p.port
AND l.timestamp >= ?
GROUP BY p.id, p.name, p.type, p.host, p.port
ORDER BY p.name ASC`
)
.all(sinceIso) as Array<Record<string, unknown>>;
return rows.map((row) => {
const total = Number(row.total_requests || 0);
const success = Number(row.success_count || 0);
const error = Number(row.error_count || 0);
const timeout = Number(row.timeout_count || 0);
const successRate = total > 0 ? Math.round((success / total) * 10000) / 100 : null;
return {
proxyId: String(row.proxy_id || ""),
name: String(row.proxy_name || ""),
type: String(row.proxy_type || "http"),
host: String(row.proxy_host || ""),
port: Number(row.proxy_port || 0),
totalRequests: total,
successCount: success,
errorCount: error,
timeoutCount: timeout,
successRate,
avgLatencyMs:
row.avg_latency_ms === null || row.avg_latency_ms === undefined
? null
: Math.round(Number(row.avg_latency_ms)),
lastSeenAt: row.last_seen_at ? String(row.last_seen_at) : null,
};
});
}
export async function bulkAssignProxyToScope(
scope: string,
scopeIds: string[],
proxyId: string | null
): Promise<{ updated: number; failed: Array<{ scopeId: string; reason: string }> }> {
const uniqueScopeIds = [
...new Set((scopeIds || []).map((id) => String(id).trim()).filter(Boolean)),
];
const failed: Array<{ scopeId: string; reason: string }> = [];
let updated = 0;
if (scope === "global") {
await assignProxyToScope("global", null, proxyId);
return { updated: 1, failed: [] };
}
for (const scopeId of uniqueScopeIds) {
try {
await assignProxyToScope(scope, scopeId, proxyId);
updated++;
} catch (error) {
failed.push({
scopeId,
reason: error instanceof Error ? error.message : "Unknown error",
});
}
}
return { updated, failed };
}
+6
View File
@@ -6,6 +6,7 @@ import { getDbInstance } from "./core";
import { backupDbFile } from "./backup";
import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts";
import { invalidateDbCache } from "./readCache";
import { resolveProxyForConnectionFromRegistry } from "./proxies";
type JsonRecord = Record<string, unknown>;
type PricingModels = Record<string, JsonRecord>;
@@ -389,6 +390,11 @@ export async function deleteProxyForLevel(level: string, id: string | null) {
}
export async function resolveProxyForConnection(connectionId: string) {
const registryResolved = await resolveProxyForConnectionFromRegistry(connectionId);
if (registryResolved?.proxy) {
return registryResolved;
}
const config = await getProxyConfig();
if (connectionId && config.keys?.[connectionId]) {
+16
View File
@@ -89,6 +89,22 @@ export {
setProxyConfig,
} from "./db/settings";
export {
// Proxy Registry
listProxies,
getProxyById,
createProxy,
updateProxy,
deleteProxyById,
getProxyAssignments,
getProxyWhereUsed,
assignProxyToScope,
resolveProxyForConnectionFromRegistry,
migrateLegacyProxyConfigToRegistry,
getProxyHealthStats,
bulkAssignProxyToScope,
} from "./db/proxies";
export {
// Pricing Sync
getSyncedPricing,
+218
View File
@@ -0,0 +1,218 @@
/**
* Local Provider Health Check
*
* Background polling of local provider_nodes (localhost) to detect
* when they are up or down. Uses GET /models with a 5s timeout.
*
* Health status is stored in-memory (no DB migration needed).
* Backoff schedule: 30s 60s 120s 300s max on consecutive failures.
* Resets to 30s on first success after failure.
*
* Uses Promise.allSettled so one slow/down node doesn't block others.
*/
import { getProviderNodes } from "@/lib/localDb";
// ── Types ────────────────────────────────────────────────────────────────
export interface HealthStatus {
nodeId: string;
prefix: string;
isHealthy: boolean;
lastCheck: Date;
lastError?: string;
consecutiveFailures: number;
responseTimeMs?: number;
}
// ── Config ───────────────────────────────────────────────────────────────
const BACKOFF_SCHEDULE = [30_000, 60_000, 120_000, 300_000];
const CHECK_TIMEOUT_MS = 5_000;
const INITIAL_DELAY_MS = 15_000; // Wait for server boot before first sweep
const LOG_PREFIX = "[LocalHealthCheck]";
// ── State ────────────────────────────────────────────────────────────────
const healthCache = new Map<string, HealthStatus>();
let initialized = false;
let sweepTimer: ReturnType<typeof setTimeout> | null = null;
// ── Helpers ──────────────────────────────────────────────────────────────
function isLocalhostUrl(baseUrl: string): boolean {
try {
const u = new URL(baseUrl);
// Block credentials in URL to prevent SSRF via user@host (e.g., http://localhost@evil.com)
if (u.username || u.password) return false;
// Note: URL.hostname returns "[::1]" WITH brackets for IPv6 — both forms checked.
// Verified: node -e "new URL('http://[::1]:8080').hostname" → "[::1]"
return (
u.hostname === "localhost" ||
u.hostname === "127.0.0.1" ||
u.hostname === "::1" ||
u.hostname === "[::1]"
);
} catch {
return false;
}
}
function getNextInterval(failures: number): number {
return BACKOFF_SCHEDULE[Math.min(failures, BACKOFF_SCHEDULE.length - 1)];
}
// ── Core ─────────────────────────────────────────────────────────────────
async function checkNode(node: {
id: string;
prefix: string;
baseUrl: string;
}): Promise<HealthStatus> {
const url = `${node.baseUrl.replace(/\/+$/, "")}/models`;
const start = Date.now();
const prev = healthCache.get(node.id);
try {
const res = await fetch(url, { signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
// Consume/cancel response body to free resources
res.body?.cancel().catch(() => {});
const isHealthy = res.ok || res.status === 401; // 401 = server up but auth required
return {
nodeId: node.id,
prefix: node.prefix,
isHealthy,
lastCheck: new Date(),
consecutiveFailures: isHealthy ? 0 : (prev?.consecutiveFailures ?? 0) + 1,
responseTimeMs: Date.now() - start,
lastError: isHealthy ? undefined : `HTTP ${res.status}`,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Connection failed";
return {
nodeId: node.id,
prefix: node.prefix,
isHealthy: false,
lastCheck: new Date(),
consecutiveFailures: (prev?.consecutiveFailures ?? 0) + 1,
responseTimeMs: Date.now() - start,
lastError: message,
};
}
}
let sweepInProgress = false;
/** Single sweep: check all local provider_nodes in parallel. */
export async function sweep(): Promise<void> {
if (sweepInProgress) return; // Prevent concurrent sweeps
sweepInProgress = true;
try {
let nodes: Array<{ id: string; prefix: string; baseUrl: string }>;
try {
const raw = await getProviderNodes();
nodes = (Array.isArray(raw) ? raw : []).filter(
(n: Record<string, unknown>) =>
typeof n.baseUrl === "string" && isLocalhostUrl(n.baseUrl as string)
) as Array<{ id: string; prefix: string; baseUrl: string }>;
} catch (err) {
console.error(LOG_PREFIX, "Failed to load provider_nodes:", err);
return;
}
// Prune stale entries for deleted nodes
const currentNodeIds = new Set(nodes.map((n) => n.id));
for (const key of healthCache.keys()) {
if (!currentNodeIds.has(key)) healthCache.delete(key);
}
if (nodes.length === 0) return;
const results = await Promise.allSettled(nodes.map((node) => checkNode(node)));
for (const result of results) {
if (result.status === "fulfilled") {
const status = result.value;
const prev = healthCache.get(status.nodeId);
// Log state transitions
if (prev && prev.isHealthy !== status.isHealthy) {
const emoji = status.isHealthy ? "✅" : "❌";
console.log(
LOG_PREFIX,
`${emoji} ${status.prefix} is now ${status.isHealthy ? "healthy" : "unhealthy"}${status.lastError ? ` (${status.lastError})` : ""} [${status.responseTimeMs}ms]`
);
}
healthCache.set(status.nodeId, status);
}
}
} finally {
sweepInProgress = false;
// Schedule next sweep with backoff based on worst-case failure count
scheduleSweep();
}
}
function scheduleSweep(): void {
if (!initialized) return; // Don't schedule if stopped
if (sweepTimer) clearTimeout(sweepTimer);
// Use the maximum consecutive failures across all nodes to determine interval
let maxFailures = 0;
for (const status of healthCache.values()) {
if (status.consecutiveFailures > maxFailures) {
maxFailures = status.consecutiveFailures;
}
}
const interval = getNextInterval(maxFailures);
sweepTimer = setTimeout(sweep, interval);
}
// ── Public API ───────────────────────────────────────────────────────────
/** Get health status for a specific provider_node. */
export function getHealthStatus(nodeId: string): HealthStatus | undefined {
return healthCache.get(nodeId);
}
/** Check if a provider_node is healthy. Returns true if never checked (optimistic). */
export function isNodeHealthy(nodeId: string): boolean {
const status = healthCache.get(nodeId);
return status?.isHealthy ?? true;
}
/** Get all health statuses (for monitoring API). */
export function getAllHealthStatuses(): Record<string, HealthStatus> {
return Object.fromEntries(healthCache);
}
/** Start the health check scheduler (idempotent). */
export function initLocalHealthCheck(): void {
if (initialized) return;
initialized = true;
console.log(
LOG_PREFIX,
`Starting local provider health check (initial delay ${INITIAL_DELAY_MS / 1000}s)`
);
// Delay first sweep to let the server finish booting
sweepTimer = setTimeout(() => {
sweep().catch((err) => console.error(LOG_PREFIX, "Initial sweep failed:", err));
}, INITIAL_DELAY_MS);
}
/** Stop the scheduler (for tests / hot-reload). */
export function stopLocalHealthCheck(): void {
if (sweepTimer) {
clearTimeout(sweepTimer);
sweepTimer = null;
}
initialized = false;
}
// Auto-initialize on first import (same pattern as tokenHealthCheck.ts:272)
initLocalHealthCheck();
+2 -2
View File
@@ -1,6 +1,6 @@
import { KIMI_CODING_CONFIG } from "../constants/oauth";
import { randomUUID } from "node:crypto";
import { hostname } from "node:os";
import { randomUUID } from "crypto";
import { hostname } from "os";
// Generate device ID (persistent per installation)
const DEVICE_ID = randomUUID();
+1 -1
View File
@@ -10,7 +10,7 @@
* @module lib/semanticCache
*/
import crypto from "node:crypto";
import crypto from "crypto";
import { LRUCache } from "./cacheLayer";
import { getDbInstance } from "./db/core";
+54
View File
@@ -0,0 +1,54 @@
/**
* Kiro IDE MITM Configuration (#336)
*
* Kiro IDE removed the Base URL / API Key configuration UI.
* To route Kiro's traffic through OmniRoute, we intercept it using MITM,
* similar to the existing Antigravity/Claude Code implementation.
*
* Kiro IDE uses the Anthropic API at https://api.anthropic.com:
* - Main endpoint: POST /v1/messages
* - Auth header: x-api-key: <key>
* - User-Agent contains: "kiro" or "Kiro"
*
* To use: Install OmniRoute's MITM certificate, then run:
* omniroute mitm start --targets kiro
*
* The MITM server intercepts requests to api.anthropic.com and forwards
* them to the OmniRoute proxy (localhost:20128) instead.
*/
export interface MitmTarget {
id: string;
name: string;
description: string;
targetHost: string;
targetPort: number;
localPort: number;
userAgentPattern: string | null;
apiEndpoints: string[];
authHeader: string;
instructions: string[];
referenceIde?: string;
}
/** Kiro IDE MITM profile */
export const KIRO_MITM_PROFILE: MitmTarget = {
id: "kiro",
name: "Kiro IDE",
description:
"Intercepts Kiro IDE requests to api.anthropic.com and routes them through OmniRoute.",
targetHost: "api.anthropic.com",
targetPort: 443,
localPort: 20130,
userAgentPattern: null, // Kiro does not expose a stable User-Agent
apiEndpoints: ["/v1/messages"],
authHeader: "x-api-key",
instructions: [
"1. Install OmniRoute's root certificate: run `omniroute cert install` or go to Settings → MITM Certificates",
"2. Start the MITM proxy: `omniroute mitm start --target kiro`",
"3. Set your system HTTP proxy to 127.0.0.1:20130 (or use transparent MITM via DNS override)",
"4. Open Kiro IDE — API calls will be automatically routed through OmniRoute.",
"5. Verify: check the Proxy Logs in OmniRoute dashboard and look for provider=anthropic source=mitm",
],
referenceIde: "antigravity", // Same MITM infrastructure as Antigravity
};
+240 -80
View File
@@ -33,7 +33,24 @@ const LEVEL_LABELS = {
* @param {string} [props.levelLabel] display name for the level
* @param {Function} [props.onSaved] callback after save
*/
export default function ProxyConfigModal({ isOpen, onClose, level, levelId, levelLabel, onSaved }: { isOpen: any; onClose: any; level: any; levelId?: any; levelLabel?: any; onSaved?: any }) {
export default function ProxyConfigModal({
isOpen,
onClose,
level,
levelId,
levelLabel,
onSaved,
}: {
isOpen: any;
onClose: any;
level: any;
levelId?: any;
levelLabel?: any;
onSaved?: any;
}) {
const [mode, setMode] = useState("saved");
const [savedProxies, setSavedProxies] = useState([]);
const [selectedProxyId, setSelectedProxyId] = useState("");
const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http");
const [host, setHost] = useState("");
const [port, setPort] = useState("");
@@ -63,6 +80,36 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
const loadProxy = async () => {
try {
let hasSavedAssignment = false;
const registryRes = await fetch("/api/settings/proxies");
if (registryRes.ok) {
const registryPayload = await registryRes.json();
setSavedProxies(Array.isArray(registryPayload?.items) ? registryPayload.items : []);
} else {
setSavedProxies([]);
}
const scope = level === "key" ? "account" : level;
const assignmentParams = new URLSearchParams({ scope });
if (level !== "global" && levelId) {
assignmentParams.set("scopeId", levelId);
}
const assignmentRes = await fetch(`/api/settings/proxies/assignments?${assignmentParams}`);
if (assignmentRes.ok) {
const assignmentPayload = await assignmentRes.json();
const items = Array.isArray(assignmentPayload?.items) ? assignmentPayload.items : [];
const target = items[0];
if (target?.proxyId) {
setMode("saved");
setSelectedProxyId(target.proxyId);
setHasOwnProxy(true);
hasSavedAssignment = true;
} else {
setMode("custom");
setSelectedProxyId("");
}
}
// Load own proxy
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
@@ -85,9 +132,12 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
"SOCKS5 is configured but hidden because NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false."
);
}
if (!hasSavedAssignment) setMode("custom");
} else {
resetFields();
setHasOwnProxy(false);
if (!hasSavedAssignment) {
setHasOwnProxy(false);
}
}
}
@@ -130,28 +180,70 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
};
const handleSave = async () => {
if (!host.trim()) return;
if (mode === "saved" && !selectedProxyId) {
setFormError("Select a saved proxy before saving.");
return;
}
if (mode === "custom" && !host.trim()) return;
setFormError(null);
setSaving(true);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
const res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
const scope = level === "key" ? "account" : level;
let res;
if (mode === "saved") {
res = await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: selectedProxyId,
}),
});
if (res.ok) {
const clearParams = new URLSearchParams({ level });
if (levelId) clearParams.set("id", levelId);
await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" });
}
} else {
const clearAssignmentRes = await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: null,
}),
});
const clearAssignmentPayload = await clearAssignmentRes.json().catch(() => ({}));
if (!clearAssignmentRes.ok) {
setFormError(clearAssignmentPayload?.error?.message || "Failed to clear saved proxy");
return;
}
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
}
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to save proxy configuration");
return;
}
setHasOwnProxy(true);
if (mode === "custom") {
setSelectedProxyId("");
}
onSaved?.();
} catch (error) {
console.error("Error saving proxy:", error);
@@ -165,6 +257,17 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
setFormError(null);
setSaving(true);
try {
const scope = level === "key" ? "account" : level;
await fetch("/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope,
scopeId: level === "global" ? null : levelId,
proxyId: null,
}),
});
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`, { method: "DELETE" });
@@ -175,6 +278,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
}
resetFields();
setHasOwnProxy(false);
setSelectedProxyId("");
setTestResult(null);
onSaved?.();
} catch (error) {
@@ -186,6 +290,10 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
};
const handleTest = async () => {
if (mode === "saved") {
setFormError("Use custom mode to run manual connection test.");
return;
}
if (!host.trim()) return;
setFormError(null);
setTesting(true);
@@ -248,93 +356,145 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
{/* Proxy Type Selector */}
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
Source
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
proxyType === t.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t.label}
</button>
))}
<div className="flex gap-2">
<button
onClick={() => setMode("saved")}
className={`px-3 py-2 rounded text-sm border transition-colors ${
mode === "saved"
? "bg-primary text-white border-primary"
: "bg-bg-subtle text-text-muted border-border"
}`}
>
Saved Proxy
</button>
<button
onClick={() => setMode("custom")}
className={`px-3 py-2 rounded text-sm border transition-colors ${
mode === "custom"
? "bg-primary text-white border-primary"
: "bg-bg-subtle text-text-muted border-border"
}`}
>
Custom
</button>
</div>
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Host
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
{mode === "saved" && (
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Port
Saved Proxy
</label>
<input
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={getDefaultPort(proxyType)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
<select
value={selectedProxyId}
onChange={(e) => setSelectedProxyId(e.target.value)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary"
>
<option value="">Select saved proxy...</option>
{savedProxies.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name} ({item.type}://{item.host}:{item.port})
</option>
))}
</select>
</div>
</div>
)}
{/* Auth Toggle */}
<div>
<button
onClick={() => setShowAuth(!showAuth)}
className="flex items-center gap-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
Authentication (optional)
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
{mode === "custom" && (
<>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
proxyType === t.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Username
Host
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Password
Port
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={getDefaultPort(proxyType)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
)}
</div>
{/* Auth Toggle */}
<div>
<button
onClick={() => setShowAuth(!showAuth)}
className="flex items-center gap-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
Authentication (optional)
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
)}
</div>
</>
)}
{/* Test Result */}
{formError && (
@@ -390,7 +550,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
icon="speed"
onClick={handleTest}
loading={testing}
disabled={!host.trim()}
disabled={mode !== "custom" || !host.trim()}
>
Test Connection
</Button>
@@ -416,7 +576,7 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
icon="save"
onClick={handleSave}
loading={saving}
disabled={!host.trim()}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Save
</Button>
+20
View File
@@ -360,6 +360,26 @@ export const APIKEY_PROVIDERS = {
hasFree: true,
freeNote: "Free Inference API for thousands of models (Whisper, VITS, SDXL…)",
},
synthetic: {
id: "synthetic",
alias: "synthetic",
name: "Synthetic",
icon: "verified_user",
color: "#6366F1",
textIcon: "SY",
website: "https://synthetic.new",
passthroughModels: true,
},
"kilo-gateway": {
id: "kilo-gateway",
alias: "kg",
name: "Kilo Gateway",
icon: "hub",
color: "#617A91",
textIcon: "KG",
website: "https://kilo.ai",
passthroughModels: true,
},
vertex: {
id: "vertex",
alias: "vertex",
+1 -1
View File
@@ -9,7 +9,7 @@
*/
import { AsyncLocalStorage } from "node:async_hooks";
import crypto from "node:crypto";
import crypto from "crypto";
const correlationStore = new AsyncLocalStorage();
+2 -6
View File
@@ -10,9 +10,8 @@
* @module shared/utils/requestId
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { randomUUID } from "crypto";
const requestIdStore = new AsyncLocalStorage();
@@ -66,10 +65,7 @@ export function addRequestIdHeader(headers = {}) {
* @returns {Response} Response with request ID header
*/
export function attachRequestIdToResponse(request, response) {
const requestId =
getRequestId() ||
request?.headers?.get?.("x-request-id") ||
randomUUID();
const requestId = getRequestId() || request?.headers?.get?.("x-request-id") || randomUUID();
const headers = new Headers(response.headers);
headers.set("x-request-id", requestId);
+62 -1
View File
@@ -551,7 +551,7 @@ export const removeModelAliasSchema = z.object({
from: z.string().trim().min(1),
});
const proxyConfigSchema = z
export const proxyConfigSchema = z
.object({
type: z
.preprocess(
@@ -621,6 +621,67 @@ export const testProxySchema = z.object({
}),
});
export const createProxyRegistrySchema = z
.object({
name: z.string().trim().min(1, "name is required").max(120),
type: z
.preprocess(
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
z.enum(["http", "https", "socks5"])
)
.optional()
.default("http"),
host: z.string().trim().min(1, "host is required").max(255),
port: z.coerce.number().int().min(1).max(65535),
username: z.string().optional(),
password: z.string().optional(),
region: z.string().trim().max(64).nullable().optional(),
notes: z.string().trim().max(1000).nullable().optional(),
status: z.enum(["active", "inactive"]).optional().default("active"),
})
.strict();
export const updateProxyRegistrySchema = createProxyRegistrySchema.partial().extend({
id: z.string().trim().min(1, "id is required"),
});
export const proxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),
scopeId: z.string().trim().nullable().optional(),
proxyId: z.string().trim().nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.scope !== "global" && !value.scopeId?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "scopeId is required for provider/account/combo/key scope",
path: ["scopeId"],
});
}
});
export const bulkProxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),
scopeIds: z.array(z.string().trim().min(1)).optional().default([]),
proxyId: z.string().trim().nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (
value.scope !== "global" &&
(!Array.isArray(value.scopeIds) || value.scopeIds.length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "scopeIds is required for provider/account/combo/key scope",
path: ["scopeIds"],
});
}
});
const jsonRecordSchema = z.record(z.string(), z.unknown());
const nonEmptyJsonRecordSchema = jsonRecordSchema.refine(
(value) => Object.keys(value).length > 0,
+2 -1
View File
@@ -382,7 +382,8 @@ async function handleSingleModelChat(
credentials.connectionId,
result.status,
result.error,
provider
provider,
model
);
if (shouldFallback) {
+134 -63
View File
@@ -14,6 +14,8 @@ import {
isModelLocked,
lockModel,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "../utils/logger";
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
@@ -52,6 +54,8 @@ interface RecoverableConnectionState {
}
const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
const MIN_QUOTA_THRESHOLD_PERCENT = 1;
const MAX_QUOTA_THRESHOLD_PERCENT = 100;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
@@ -111,6 +115,84 @@ function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
};
}
interface QuotaLimitPolicy {
enabled: boolean;
thresholdPercent: number;
windows: string[];
}
function normalizeQuotaThreshold(value: unknown, fallback = CODEX_QUOTA_THRESHOLD_PERCENT): number {
const parsed = toNumber(value, fallback);
return Math.min(MAX_QUOTA_THRESHOLD_PERCENT, Math.max(MIN_QUOTA_THRESHOLD_PERCENT, parsed));
}
function normalizeWindowName(windowName: unknown): string | null {
if (typeof windowName !== "string") return null;
const normalized = windowName.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
function getLegacyCodexWindows(providerSpecificData: JsonRecord): string[] {
const codexPolicy = getCodexLimitPolicy(providerSpecificData);
const windows: string[] = [];
if (codexPolicy.use5h) windows.push("session");
if (codexPolicy.useWeekly) windows.push("weekly");
return windows;
}
export function resolveQuotaLimitPolicy(
provider: string,
providerSpecificData: JsonRecord
): QuotaLimitPolicy {
const rawPolicy = asRecord(providerSpecificData.limitPolicy);
const rawWindows = Array.isArray(rawPolicy.windows) ? rawPolicy.windows : [];
const windows = rawWindows.map(normalizeWindowName).filter(Boolean) as string[];
if (provider === "codex") {
const fallbackWindows = getLegacyCodexWindows(providerSpecificData);
const defaultWindows = windows.length > 0 ? windows : fallbackWindows;
const enabled = toBooleanOrDefault(rawPolicy.enabled, defaultWindows.length > 0);
return {
enabled,
thresholdPercent: normalizeQuotaThreshold(rawPolicy.thresholdPercent),
windows: defaultWindows,
};
}
return {
enabled: toBooleanOrDefault(rawPolicy.enabled, false),
thresholdPercent: normalizeQuotaThreshold(rawPolicy.thresholdPercent),
windows,
};
}
export function evaluateQuotaLimitPolicy(
provider: string,
connection: ProviderConnectionView
): { blocked: boolean; reasons: string[]; resetAt: string | null } {
const policy = resolveQuotaLimitPolicy(provider, connection.providerSpecificData);
if (!policy.enabled || policy.windows.length === 0) {
return { blocked: false, reasons: [], resetAt: null };
}
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
for (const windowName of policy.windows) {
const status = getQuotaWindowStatus(connection.id, windowName, policy.thresholdPercent);
if (!status?.reachedThreshold) continue;
reasons.push(`${windowName} usage ${Math.round(status.usedPercentage)}%`);
resetCandidates.push(status.resetAt);
}
return {
blocked: reasons.length > 0,
reasons,
resetAt: getEarliestFutureDate(resetCandidates),
};
}
function parseFutureDateMs(value: string | null): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
@@ -260,76 +342,48 @@ export async function getProviderCredentials(
}
let policyEligibleConnections = availableConnections;
if (provider === "codex") {
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
policyEligibleConnections = availableConnections.filter((connection) => {
const policy = getCodexLimitPolicy(connection.providerSpecificData);
const sessionStatus = policy.use5h
? getQuotaWindowStatus(connection.id, "session", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
const weeklyStatus = policy.useWeekly
? getQuotaWindowStatus(connection.id, "weekly", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
policyEligibleConnections = availableConnections.filter((connection) => {
const evaluation = evaluateQuotaLimitPolicy(provider, connection);
if (!evaluation.blocked) return true;
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
if (policy.use5h && sessionStatus?.reachedThreshold) {
reasons.push(`5h usage ${Math.round(sessionStatus.usedPercentage)}%`);
resetCandidates.push(sessionStatus.resetAt);
}
if (policy.useWeekly && weeklyStatus?.reachedThreshold) {
reasons.push(`weekly usage ${Math.round(weeklyStatus.usedPercentage)}%`);
resetCandidates.push(weeklyStatus.resetAt);
}
if (reasons.length > 0) {
const nextResetAt = getEarliestFutureDate(resetCandidates);
blockedByPolicy.push({
id: connection.id,
reasons,
resetAt: nextResetAt,
});
return false;
}
return true;
blockedByPolicy.push({
id: connection.id,
reasons: evaluation.reasons,
resetAt: evaluation.resetAt,
});
return false;
});
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(
blockedByPolicy.map((entry) => entry.resetAt)
);
const earliestResetMs = parseFutureDateMs(earliestResetAt);
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(blockedByPolicy.map((entry) => entry.resetAt));
const earliestResetMs = parseFutureDateMs(earliestResetAt);
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: "All Codex accounts reached configured quota threshold",
lastErrorCode: 429,
};
}
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: `All ${provider} accounts reached configured quota threshold`,
lastErrorCode: 429,
};
}
// Quota-aware: prioritize accounts with available quota
@@ -563,6 +617,23 @@ export async function markAccountUnavailable(
);
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
// ── Local provider 404: model-only lockout, connection stays active ──
// Detection: URL-based only (apiKey===null heuristic was too broad — could match
// cloud providers with non-standard auth stored in providerSpecificData).
const connBaseUrl = (conn?.providerSpecificData as Record<string, unknown>)?.baseUrl as
| string
| undefined;
if (isLocalProvider(connBaseUrl) && status === 404 && provider && model) {
const localCooldown = COOLDOWN_MS.notFoundLocal;
lockModel(provider, connectionId, model, "local_not_found", localCooldown);
log.info(
"AUTH",
`Local 404 for ${model} — model-only lockout ${localCooldown / 1000}s (connection stays active)`
);
return { shouldFallback: true, cooldownMs: localCooldown };
}
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
+219
View File
@@ -0,0 +1,219 @@
import { test, expect } from "@playwright/test";
type ProxyStub = {
id: string;
name: string;
type: string;
host: string;
port: number;
status: string;
region?: string | null;
notes?: string | null;
};
test.describe("Proxy Registry smoke flow", () => {
test("create, edit, bulk-assign modal, and delete proxy from settings advanced", async ({
page,
}) => {
const state: {
proxies: ProxyStub[];
nextId: number;
bulkAssignCalls: number;
} = {
proxies: [],
nextId: 1,
bulkAssignCalls: 0,
};
await page.route("**/api/settings/proxy?level=global", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ proxy: null }),
});
});
await page.route("**/api/settings/proxies/health?hours=24", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: state.proxies.map((p) => ({
proxyId: p.id,
totalRequests: 0,
successRate: null,
avgLatencyMs: null,
lastSeenAt: null,
})),
total: state.proxies.length,
windowHours: 24,
}),
});
});
await page.route("**/api/settings/proxies/bulk-assign", async (route) => {
if (route.request().method() !== "PUT") {
await route.fulfill({
status: 405,
contentType: "application/json",
body: JSON.stringify({ error: "method not allowed in test stub" }),
});
return;
}
state.bulkAssignCalls += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
scope: "provider",
requested: 2,
updated: 2,
failed: [],
}),
});
});
await page.route("**/api/settings/proxies*", async (route) => {
const req = route.request();
const method = req.method();
const url = new URL(req.url());
const id = url.searchParams.get("id");
const whereUsed = url.searchParams.get("whereUsed");
if (method === "GET" && id && whereUsed === "1") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ count: 0, assignments: [] }),
});
return;
}
if (method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: state.proxies, total: state.proxies.length }),
});
return;
}
if (method === "POST") {
const payload = req.postDataJSON() as Partial<ProxyStub>;
const proxy: ProxyStub = {
id: `proxy-${state.nextId++}`,
name: payload.name || "Proxy",
type: payload.type || "http",
host: payload.host || "localhost",
port: Number(payload.port || 8080),
status: payload.status || "active",
region: payload.region || null,
notes: payload.notes || null,
};
state.proxies.unshift(proxy);
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(proxy),
});
return;
}
if (method === "PATCH") {
const payload = req.postDataJSON() as Partial<ProxyStub> & { id?: string };
const index = state.proxies.findIndex((p) => p.id === payload.id);
if (index === -1) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: { message: "Proxy not found", type: "not_found" } }),
});
return;
}
const updated = {
...state.proxies[index],
...payload,
} as ProxyStub;
state.proxies[index] = updated;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(updated),
});
return;
}
if (method === "DELETE") {
if (!id) {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({ error: { message: "id is required", type: "invalid_request" } }),
});
return;
}
state.proxies = state.proxies.filter((p) => p.id !== id);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
return;
}
await route.fulfill({
status: 405,
contentType: "application/json",
body: JSON.stringify({ error: "method not allowed in test stub" }),
});
});
await page.goto("/dashboard/settings?tab=advanced");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible();
await page.getByTestId("proxy-registry-open-create").click();
const createDialog = page.getByRole("dialog");
await expect(createDialog.getByText("Create Proxy")).toBeVisible();
await createDialog.getByTestId("proxy-registry-name-input").fill("Registry Smoke Proxy");
await createDialog.getByTestId("proxy-registry-host-input").fill("smoke.local");
await createDialog.getByRole("button", { name: "Save" }).click();
await expect(page.locator("table")).toContainText("Registry Smoke Proxy");
await expect(page.locator("table")).toContainText("http://smoke.local:8080");
const row = page.locator("tr", { hasText: "Registry Smoke Proxy" });
await row.getByRole("button", { name: "Edit" }).click();
const editDialog = page.getByRole("dialog");
await expect(editDialog.getByText("Edit Proxy")).toBeVisible();
await editDialog.getByTestId("proxy-registry-host-input").fill("smoke-updated.local");
await editDialog.getByRole("button", { name: "Save" }).click();
await expect(page.locator("table")).toContainText("http://smoke-updated.local:8080");
await page.getByTestId("proxy-registry-open-bulk").click();
const bulkDialog = page.getByRole("dialog");
await expect(bulkDialog.getByText("Bulk Proxy Assignment")).toBeVisible();
await bulkDialog.getByTestId("proxy-registry-bulk-scopeids-input").fill("openai,anthropic");
await bulkDialog.getByTestId("proxy-registry-bulk-apply").click();
await expect
.poll(() => state.bulkAssignCalls, {
message: "Expected bulk assign API to be called exactly once",
})
.toBe(1);
await row.getByRole("button", { name: "Delete" }).click();
await expect
.poll(() => state.proxies.some((proxy) => proxy.name === "Registry Smoke Proxy"))
.toBe(false);
await expect(page.locator("tr", { hasText: "Registry Smoke Proxy" })).toHaveCount(0);
});
});
@@ -0,0 +1,168 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-registry-flow-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const proxySettingsRoute = await import("../../src/app/api/settings/proxies/route.ts");
const proxyAssignmentsRoute =
await import("../../src/app/api/settings/proxies/assignments/route.ts");
const proxyBulkRoute = await import("../../src/app/api/settings/proxies/bulk-assign/route.ts");
const proxyHealthRoute = await import("../../src/app/api/settings/proxies/health/route.ts");
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("integration: proxy registry full flow works and enforces safe delete", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "proxy-flow-account",
apiKey: "sk-flow-test",
});
const createRes = await proxySettingsRoute.POST(
new Request("http://localhost/api/settings/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Flow Proxy",
type: "http",
host: "flow.local",
port: 8080,
}),
})
);
assert.equal(createRes.status, 201);
const createdProxy = await createRes.json();
assert.ok(createdProxy.id);
const assignRes = await proxyAssignmentsRoute.PUT(
new Request("http://localhost/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "account",
scopeId: connection.id,
proxyId: createdProxy.id,
}),
})
);
assert.equal(assignRes.status, 200);
const resolveRes = await proxyAssignmentsRoute.GET(
new Request(
`http://localhost/api/settings/proxies/assignments?resolveConnectionId=${connection.id}`
)
);
assert.equal(resolveRes.status, 200);
const resolved = await resolveRes.json();
assert.equal(resolved.level, "account");
assert.equal(resolved.source, "registry");
assert.equal(resolved.proxy.host, "flow.local");
const bulkRes = await proxyBulkRoute.PUT(
new Request("http://localhost/api/settings/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "provider",
scopeIds: ["openai", "anthropic"],
proxyId: createdProxy.id,
}),
})
);
assert.equal(bulkRes.status, 200);
const bulkPayload = await bulkRes.json();
assert.equal(bulkPayload.updated, 2);
assert.equal(bulkPayload.failed.length, 0);
proxyLogger.logProxyEvent({
status: "success",
proxy: { type: "http", host: "flow.local", port: 8080 },
latencyMs: 90,
level: "provider",
levelId: "openai",
provider: "openai",
});
proxyLogger.logProxyEvent({
status: "error",
proxy: { type: "http", host: "flow.local", port: 8080 },
latencyMs: 240,
level: "provider",
levelId: "openai",
provider: "openai",
});
const healthRes = await proxyHealthRoute.GET(
new Request("http://localhost/api/settings/proxies/health?hours=24")
);
assert.equal(healthRes.status, 200);
const healthPayload = await healthRes.json();
const row = healthPayload.items.find((item) => item.proxyId === createdProxy.id);
assert.ok(row);
assert.equal(row.totalRequests >= 2, true);
assert.equal(row.errorCount >= 1, true);
const deleteConflictRes = await proxySettingsRoute.DELETE(
new Request(`http://localhost/api/settings/proxies?id=${createdProxy.id}`, {
method: "DELETE",
})
);
assert.equal(deleteConflictRes.status, 409);
const deleteConflict = await deleteConflictRes.json();
assert.equal(deleteConflict.error.type, "conflict");
assert.equal(typeof deleteConflict.requestId, "string");
assert.equal(deleteConflict.requestId.length > 0, true);
const clearAccountAssignment = await proxyAssignmentsRoute.PUT(
new Request("http://localhost/api/settings/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "account",
scopeId: connection.id,
proxyId: null,
}),
})
);
assert.equal(clearAccountAssignment.status, 200);
const clearProviderBulk = await proxyBulkRoute.PUT(
new Request("http://localhost/api/settings/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "provider",
scopeIds: ["openai", "anthropic"],
proxyId: null,
}),
})
);
assert.equal(clearProviderBulk.status, 200);
const deleteOkRes = await proxySettingsRoute.DELETE(
new Request(`http://localhost/api/settings/proxies?id=${createdProxy.id}`, {
method: "DELETE",
})
);
assert.equal(deleteOkRes.status, 200);
const deleteOkPayload = await deleteOkRes.json();
assert.equal(deleteOkPayload.success, true);
});
+180
View File
@@ -0,0 +1,180 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } =
await import("../../open-sse/translator/request/openai-responses.ts");
// ──────────────────────────────────────────────────────────────────────────────
// Responses API -> Chat Completions direction
// ──────────────────────────────────────────────────────────────────────────────
test("openaiResponsesToOpenAIRequest: skips function_call items with empty name", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: "hello" },
{ type: "function_call", call_id: "call_1", name: "", arguments: "{}" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {});
const messages = result.messages;
// Should have only the user message, no assistant message with empty tool call
const assistantMsgs = messages.filter((m) => m.role === "assistant");
assert.equal(assistantMsgs.length, 0, "empty-name function_call should be skipped entirely");
});
test("openaiResponsesToOpenAIRequest: skips function_call items with whitespace-only name", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: "hello" },
{ type: "function_call", call_id: "call_1", name: " ", arguments: "{}" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {});
const messages = result.messages;
const assistantMsgs = messages.filter((m) => m.role === "assistant");
assert.equal(assistantMsgs.length, 0, "whitespace-only name function_call should be skipped");
});
test("openaiResponsesToOpenAIRequest: keeps function_call items with valid name", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: "hello" },
{
type: "function_call",
call_id: "call_1",
name: "get_weather",
arguments: '{"city":"NYC"}',
},
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {});
const messages = result.messages;
const assistantMsgs = messages.filter((m) => m.role === "assistant");
assert.equal(assistantMsgs.length, 1, "valid function_call should produce assistant message");
assert.equal(assistantMsgs[0].tool_calls.length, 1);
assert.equal(assistantMsgs[0].tool_calls[0].function.name, "get_weather");
});
test("openaiResponsesToOpenAIRequest: mixed valid and empty names keeps only valid", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: "hello" },
{ type: "function_call", call_id: "call_1", name: "", arguments: "{}" },
{
type: "function_call",
call_id: "call_2",
name: "get_weather",
arguments: '{"city":"NYC"}',
},
{ type: "function_call", call_id: "call_3", name: " ", arguments: "{}" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, {});
const messages = result.messages;
const assistantMsgs = messages.filter((m) => m.role === "assistant");
assert.equal(assistantMsgs.length, 1);
assert.equal(assistantMsgs[0].tool_calls.length, 1, "only valid tool call should remain");
assert.equal(assistantMsgs[0].tool_calls[0].function.name, "get_weather");
});
// ──────────────────────────────────────────────────────────────────────────────
// Chat Completions -> Responses API direction
// ──────────────────────────────────────────────────────────────────────────────
test("openaiToOpenAIResponsesRequest: skips tool_calls with empty function name", () => {
const body = {
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "", arguments: "{}" } }],
},
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {});
const fnCalls = result.input.filter((i) => i.type === "function_call");
assert.equal(fnCalls.length, 0, "empty-name tool_call should be skipped");
});
test("openaiToOpenAIResponsesRequest: skips tool_calls with whitespace-only function name", () => {
const body = {
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_1", type: "function", function: { name: " ", arguments: "{}" } },
],
},
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {});
const fnCalls = result.input.filter((i) => i.type === "function_call");
assert.equal(fnCalls.length, 0, "whitespace-only name tool_call should be skipped");
});
test("openaiToOpenAIResponsesRequest: keeps tool_calls with valid function name", () => {
const body = {
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "get_weather", arguments: '{"city":"NYC"}' },
},
],
},
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {});
const fnCalls = result.input.filter((i) => i.type === "function_call");
assert.equal(fnCalls.length, 1);
assert.equal(fnCalls[0].name, "get_weather");
});
test("openaiToOpenAIResponsesRequest: mixed valid and empty names keeps only valid", () => {
const body = {
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_1", type: "function", function: { name: "", arguments: "{}" } },
{
id: "call_2",
type: "function",
function: { name: "get_weather", arguments: '{"city":"NYC"}' },
},
{ id: "call_3", type: "function", function: { name: " \t ", arguments: "{}" } },
],
},
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, {});
const fnCalls = result.input.filter((i) => i.type === "function_call");
assert.equal(fnCalls.length, 1, "only valid tool call should remain");
assert.equal(fnCalls[0].name, "get_weather");
});
+175
View File
@@ -0,0 +1,175 @@
import test from "node:test";
import assert from "node:assert/strict";
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } = await import(
"../../open-sse/translator/request/openai-responses.ts"
);
const { openaiToClaudeRequest } = await import(
"../../open-sse/translator/request/openai-to-claude.ts"
);
test("openaiResponsesToOpenAIRequest: filters orphaned tool messages", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "function_call_output", call_id: "call_orphan_1", output: "stale result" },
{ type: "function_call", call_id: "call_valid_1", name: "read_file", arguments: "{}" },
{ type: "function_call_output", call_id: "call_valid_1", output: "file contents" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null);
const toolMessages = result.messages.filter((m) => m.role === "tool");
assert.equal(toolMessages.length, 1, "should have exactly 1 tool message");
assert.equal(toolMessages[0].tool_call_id, "call_valid_1");
});
test("openaiResponsesToOpenAIRequest: preserves all messages when no orphans", () => {
const body = {
model: "gpt-4",
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "function_call", call_id: "call_1", name: "read_file", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: "ok" },
{ type: "function_call", call_id: "call_2", name: "write_file", arguments: "{}" },
{ type: "function_call_output", call_id: "call_2", output: "done" },
],
};
const result = openaiResponsesToOpenAIRequest("gpt-4", body, true, null);
const toolMessages = result.messages.filter((m) => m.role === "tool");
assert.equal(toolMessages.length, 2, "both valid tool results should be preserved");
});
test("openaiToOpenAIResponsesRequest: filters orphaned function_call_output", () => {
const body = {
messages: [
{ role: "system", content: "You are helpful" },
{ role: "user", content: "hello" },
{ role: "tool", tool_call_id: "call_orphan_2", content: "stale" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_valid_2", type: "function", function: { name: "ls", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "call_valid_2", content: "files" },
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null);
const outputs = result.input.filter((i) => i.type === "function_call_output");
assert.equal(outputs.length, 1, "should have exactly 1 function_call_output");
assert.equal(outputs[0].call_id, "call_valid_2");
});
test("openaiToOpenAIResponsesRequest: preserves all items when no orphans", () => {
const body = {
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: null,
tool_calls: [
{ id: "call_a", type: "function", function: { name: "ls", arguments: "{}" } },
],
},
{ role: "tool", tool_call_id: "call_a", content: "result" },
],
};
const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null);
const outputs = result.input.filter((i) => i.type === "function_call_output");
assert.equal(outputs.length, 1, "valid function_call_output should be preserved");
});
test("openaiToClaudeRequest: filters orphaned tool_result blocks", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{ role: "tool", tool_call_id: "tu_orphan_1", content: "stale result" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_valid_1", name: "read_file", input: {} }],
},
{ role: "tool", tool_call_id: "tu_valid_1", content: "file contents" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
const toolResults = [];
for (const msg of result.messages) {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result") toolResults.push(block);
}
}
}
assert.equal(toolResults.length, 1, "should have exactly 1 tool_result");
assert.equal(toolResults[0].tool_use_id, "tu_valid_1");
});
test("openaiToClaudeRequest: removes empty user messages after orphan filtering", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{ role: "tool", tool_call_id: "tu_orphan_only", content: "stale" },
{ role: "assistant", content: "I can help" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
for (const msg of result.messages) {
if (msg.role === "user" && Array.isArray(msg.content)) {
assert.ok(msg.content.length > 0, "user message should not have empty content");
}
}
});
test("openaiToClaudeRequest: removes empty assistant messages", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_1", name: "", input: {} }],
},
{ role: "assistant", content: "actual response" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
for (const msg of result.messages) {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
assert.ok(msg.content.length > 0, "assistant message should not have empty content");
}
}
});
test("openaiToClaudeRequest: preserves valid tool pairs unchanged", () => {
const body = {
_disableToolPrefix: true,
messages: [
{ role: "user", content: "hello" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_1", name: "read_file", input: {} }],
},
{ role: "tool", tool_call_id: "tu_1", content: "file data" },
{
role: "assistant",
content: [{ type: "tool_use", id: "tu_2", name: "write_file", input: {} }],
},
{ role: "tool", tool_call_id: "tu_2", content: "written" },
],
};
const result = openaiToClaudeRequest("claude-3", body, true);
const toolResults = [];
for (const msg of result.messages) {
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_result") toolResults.push(block);
}
}
}
assert.equal(toolResults.length, 2, "both valid tool_results should be preserved");
});
@@ -0,0 +1,202 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-v1-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const proxyV1Route = await import("../../src/app/api/v1/management/proxies/route.ts");
const proxyAssignmentsV1Route =
await import("../../src/app/api/v1/management/proxies/assignments/route.ts");
const proxyHealthV1Route = await import("../../src/app/api/v1/management/proxies/health/route.ts");
const proxyBulkAssignV1Route =
await import("../../src/app/api/v1/management/proxies/bulk-assign/route.ts");
const proxyLogger = await import("../../src/lib/proxyLogger.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("v1 management proxies supports create/list/pagination", async () => {
await resetStorage();
const createA = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy A",
type: "http",
host: "proxy-a.local",
port: 8080,
}),
})
);
assert.equal(createA.status, 201);
const createB = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy B",
type: "https",
host: "proxy-b.local",
port: 443,
}),
})
);
assert.equal(createB.status, 201);
const listRes = await proxyV1Route.GET(
new Request("http://localhost/api/v1/management/proxies?limit=1&offset=0")
);
assert.equal(listRes.status, 200);
const listPayload = await listRes.json();
assert.equal(Array.isArray(listPayload.items), true);
assert.equal(listPayload.items.length, 1);
assert.equal(listPayload.page.total >= 2, true);
});
test("v1 management assignments supports put and filtered get", async () => {
await resetStorage();
const providerConn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "v1-assignment",
apiKey: "sk-test-v1",
});
const createdRes = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy Assign",
type: "http",
host: "assign.local",
port: 8000,
}),
})
);
const created = await createdRes.json();
const assignRes = await proxyAssignmentsV1Route.PUT(
new Request("http://localhost/api/v1/management/proxies/assignments", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "account",
scopeId: providerConn.id,
proxyId: created.id,
}),
})
);
assert.equal(assignRes.status, 200);
const filteredRes = await proxyAssignmentsV1Route.GET(
new Request(
`http://localhost/api/v1/management/proxies/assignments?scope=account&scope_id=${providerConn.id}`
)
);
assert.equal(filteredRes.status, 200);
const payload = await filteredRes.json();
assert.equal(payload.items.length, 1);
assert.equal(payload.items[0].proxyId, created.id);
});
test("v1 management health endpoint aggregates proxy log metrics", async () => {
await resetStorage();
const createdRes = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Proxy Health",
type: "http",
host: "health.local",
port: 8080,
}),
})
);
const created = await createdRes.json();
proxyLogger.logProxyEvent({
status: "success",
proxy: { type: "http", host: "health.local", port: 8080 },
latencyMs: 120,
level: "provider",
levelId: "openai",
provider: "openai",
});
proxyLogger.logProxyEvent({
status: "error",
proxy: { type: "http", host: "health.local", port: 8080 },
latencyMs: 200,
level: "provider",
levelId: "openai",
provider: "openai",
});
const healthRes = await proxyHealthV1Route.GET(
new Request("http://localhost/api/v1/management/proxies/health?hours=24")
);
assert.equal(healthRes.status, 200);
const healthPayload = await healthRes.json();
const row = healthPayload.items.find((item) => item.proxyId === created.id);
assert.ok(row);
assert.equal(row.totalRequests >= 2, true);
assert.equal(row.errorCount >= 1, true);
});
test("v1 bulk assignment updates multiple scope IDs in one request", async () => {
await resetStorage();
const proxyRes = await proxyV1Route.POST(
new Request("http://localhost/api/v1/management/proxies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Bulk Proxy",
type: "http",
host: "bulk.local",
port: 8080,
}),
})
);
const proxy = await proxyRes.json();
const bulkRes = await proxyBulkAssignV1Route.PUT(
new Request("http://localhost/api/v1/management/proxies/bulk-assign", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scope: "provider",
scopeIds: ["openai", "anthropic"],
proxyId: proxy.id,
}),
})
);
assert.equal(bulkRes.status, 200);
const bulkPayload = await bulkRes.json();
assert.equal(bulkPayload.updated, 2);
const checkRes = await proxyAssignmentsV1Route.GET(
new Request("http://localhost/api/v1/management/proxies/assignments?scope=provider")
);
const checkPayload = await checkRes.json();
assert.equal(checkPayload.items.length >= 2, true);
});
+121
View File
@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-registry-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const proxiesDb = await import("../../src/lib/db/proxies.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("proxy registry blocks delete when proxy is still assigned", async () => {
await resetStorage();
const created = await proxiesDb.createProxy({
name: "Delete Safety Proxy",
type: "http",
host: "127.0.0.1",
port: 8080,
});
assert.ok(created?.id);
await proxiesDb.assignProxyToScope("provider", "openai", created.id);
await assert.rejects(
async () => proxiesDb.deleteProxyById(created.id),
(error) => {
assert.equal(error.status, 409);
assert.equal(error.code, "proxy_in_use");
return true;
}
);
});
test("registry assignment takes precedence over legacy proxy config", async () => {
await resetStorage();
const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "registry-precedence",
apiKey: "sk-test",
});
await settingsDb.setProxyForLevel("key", conn.id, {
type: "http",
host: "legacy-key.local",
port: 8080,
});
const providerProxy = await proxiesDb.createProxy({
name: "Provider Proxy",
type: "https",
host: "provider.local",
port: 443,
});
const accountProxy = await proxiesDb.createProxy({
name: "Account Proxy",
type: "http",
host: "account.local",
port: 8081,
});
await proxiesDb.assignProxyToScope("provider", "openai", providerProxy.id);
await proxiesDb.assignProxyToScope("account", conn.id, accountProxy.id);
const resolved = await settingsDb.resolveProxyForConnection(conn.id);
assert.equal(resolved.level, "account");
assert.equal(resolved.source, "registry");
assert.equal(resolved.proxy.host, "account.local");
});
test("legacy proxy config migration imports global/provider/key assignments", async () => {
await resetStorage();
const conn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "legacy-import",
apiKey: "sk-test-legacy",
});
await settingsDb.setProxyForLevel("global", null, {
type: "http",
host: "global.local",
port: 8080,
});
await settingsDb.setProxyForLevel("provider", "openai", {
type: "https",
host: "provider-legacy.local",
port: 443,
});
await settingsDb.setProxyForLevel("key", conn.id, {
type: "http",
host: "account-legacy.local",
port: 8082,
});
const result = await proxiesDb.migrateLegacyProxyConfigToRegistry();
assert.equal(result.skipped, false);
assert.equal(result.migrated >= 3, true);
const resolved = await settingsDb.resolveProxyForConnection(conn.id);
assert.equal(resolved.level, "account");
assert.equal(resolved.source, "registry");
assert.equal(resolved.proxy.host, "account-legacy.local");
});
@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
const auth = await import("../../src/sse/services/auth.ts");
const quotaCache = await import("../../src/domain/quotaCache.ts");
function buildConnection(id, providerSpecificData = {}) {
return {
id,
providerSpecificData,
};
}
test("resolveQuotaLimitPolicy keeps codex legacy defaults when generic policy is missing", () => {
const policy = auth.resolveQuotaLimitPolicy("codex", {
codexLimitPolicy: { use5h: true, useWeekly: false },
});
assert.equal(policy.enabled, true);
assert.deepEqual(policy.windows, ["session"]);
assert.equal(policy.thresholdPercent, 90);
});
test("resolveQuotaLimitPolicy disables non-codex policy by default", () => {
const policy = auth.resolveQuotaLimitPolicy("openai", {});
assert.equal(policy.enabled, false);
assert.deepEqual(policy.windows, []);
});
test("resolveQuotaLimitPolicy accepts generic provider policy and clamps threshold", () => {
const policy = auth.resolveQuotaLimitPolicy("openai", {
limitPolicy: {
enabled: true,
thresholdPercent: 999,
windows: ["daily", " monthly ", ""],
},
});
assert.equal(policy.enabled, true);
assert.equal(policy.thresholdPercent, 100);
assert.deepEqual(policy.windows, ["daily", "monthly"]);
});
test("evaluateQuotaLimitPolicy blocks when configured window reaches threshold", () => {
const resetAt = new Date(Date.now() + 60_000).toISOString();
quotaCache.setQuotaCache("conn-policy-1", "openai", {
daily: { remainingPercentage: 5, resetAt },
});
const result = auth.evaluateQuotaLimitPolicy(
"openai",
buildConnection("conn-policy-1", {
limitPolicy: { enabled: true, thresholdPercent: 90, windows: ["daily"] },
})
);
assert.equal(result.blocked, true);
assert.equal(result.reasons.length, 1);
assert.match(result.reasons[0], /daily usage/i);
assert.equal(result.resetAt, resetAt);
});
test("evaluateQuotaLimitPolicy does not block when no quota data exists", () => {
const result = auth.evaluateQuotaLimitPolicy(
"openai",
buildConnection("conn-policy-missing", {
limitPolicy: { enabled: true, thresholdPercent: 90, windows: ["daily"] },
})
);
assert.equal(result.blocked, false);
assert.deepEqual(result.reasons, []);
assert.equal(result.resetAt, null);
});
View File