Compare commits

...

58 Commits

Author SHA1 Message Date
diegosouzapw fb8d187f8d chore(release): v3.2.2 — Four-Stage Request Logs & Bugfixes
Build Electron Desktop App / Validate version (push) Failing after 35s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-03-28 22:11:22 -03:00
diegosouzapw 1a11301e1a Merge branch 'codex/request-log-pipeline-json' 2026-03-28 22:09:34 -03:00
R.D. 4c6cdd5c23 test: align pipeline integration assertions 2026-03-28 22:09:27 -03:00
R.D. 30a64b0dd3 test: align security hardening log helper checks 2026-03-28 22:09:27 -03:00
R.D. 04de492019 fix: add four-stage request log payloads 2026-03-28 22:09:27 -03:00
R.D. 07890df6cb test: align pipeline integration assertions 2026-03-28 22:07:20 -03:00
R.D. 2f23cfdf1c test: align security hardening log helper checks 2026-03-28 22:07:20 -03:00
R.D. 1832946d41 fix: add four-stage request log payloads 2026-03-28 22:07:20 -03:00
Diego Souza 6ec8745d2e ci: add GitHub Packages publish configuration for GHCR and NPM 2026-03-28 22:04:02 -03:00
diegosouzapw b6bbfe063b fix(sse): preserve cache_control in Claude passthrough mode (#708) 2026-03-28 22:01:38 -03:00
oyi77 48182edbd5 fix(translator): remove thoughtSignature from functionCall parts in Gemini translation
HTTP 400 "invalid argument" was triggered when OmniRoute translated OpenAI
tool_calls to Gemini format, because thoughtSignature was injected onto every
functionCall part unconditionally.

thoughtSignature is only valid on thinking/reasoning parts (those with
thought: true). The Gemini API rejects any request where a functionCall
part carries a thoughtSignature field, returning HTTP 400.

Fix: remove the thoughtSignature field from functionCall parts. The thinking
parts that legitimately require thoughtSignature (emitted when a message has
reasoning_content) are unchanged.

Adds regression test (T43) with three cases:
- single tool call: no thoughtSignature on functionCall part
- multiple tool calls: none carry thoughtSignature
- thinking part regression guard: thoughtSignature still present on thought parts

Fixes #725
2026-03-28 21:57:15 -03:00
Diego Rodrigues de Sa e Souza fc24361aa6 Merge pull request #726 from diegosouzapw/release/v3.2.1
Build Electron Desktop App / Validate version (push) Failing after 26s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
chore(release): v3.2.1 — context pinning fix + global fallback
2026-03-28 21:19:24 -03:00
diegosouzapw cec833afc6 chore(release): v3.2.1 — context pinning fix + global fallback provider 2026-03-28 21:13:14 -03:00
diegosouzapw f1cddba938 feat: add global fallback provider support (#689)
When all combo models are exhausted (502/503), OmniRoute now checks for
a globalFallbackModel setting and attempts one last request through it
before returning the error. Settings stored in key_value table, no
migration needed.
2026-03-28 21:10:29 -03:00
diegosouzapw a0acdfdcb9 fix: context pinning bypass during tool-call responses (#721)
Non-streaming: Fixed json.messages check to use json.choices[0].message
(OpenAI format). Streaming: inject pin tag before finish_reason chunk for
tool-call-only streams. injectModelTag now appends synthetic assistant
message when content is null/array (tool_calls).
2026-03-28 21:04:47 -03:00
Diego Rodrigues de Sa e Souza 6637f294df chore: release v3.2.0 (#722)
Build Electron Desktop App / Validate version (push) Failing after 28s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-03-28 20:45:18 -03:00
dependabot[bot] ad8a444105 deps: bump path-to-regexp from 8.3.0 to 8.4.0 (#715)
Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 8.3.0 to 8.4.0.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v8.3.0...v8.4.0)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-version: 8.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-28 20:39:29 -03:00
Chris 877cfa0071 feat: add GLM Coding usage/quota tracking with Z.AI session quota (#698)
* feat: add GLM Coding usage/quota tracking with Z.AI session quota

Add GLM to the usage tracking pipeline: usage API route, Z.AI quota
fetcher (TOKENS_LIMIT percentage-based), quota parser, and Provider
Limits UI. Adds API region dropdown (International/China) to Add/Edit
connection modals. Displays session quota with plan level.

* fix: address PR review feedback for GLM usage tracking

- Remove explicit `any` types from getGlmUsage (fix lint budget)
- Fix empty string fallback for plan level
- Remove duplicate `case "glm"` in quota parser (identical to default)
- Skip OAuth refresh flow for GLM (API key auth) in usage route

* fix: upgrade path-to-regexp to fix ReDoS vulnerability (GHSA-j3q9-mxjg-w52f, GHSA-27v5-c462-wpq7)

---------

Co-authored-by: Chris Staley <christopher-s@users.noreply.github.com>
2026-03-28 20:39:24 -03:00
Paijo e6f0a780b7 feat(dashboard): add Cache Management page with stats, hit rate, and targeted invalidation (#701)
Adds a new /dashboard/cache page that surfaces the existing but UI-less
semantic cache infrastructure.

Changes:
- New page: src/app/(dashboard)/dashboard/cache/page.tsx
  - Live stats: memory entries, DB entries, cache hits, tokens saved
  - Hit rate progress bar with color coding (green/yellow/red)
  - Hits/Misses/Total breakdown
  - Idempotency layer stats (active dedup keys + window)
  - Cache behavior info panel
  - Clear All button
  - Auto-refresh every 10s
- Enhanced API: src/app/api/cache/route.ts
  - DELETE ?model=<name> — invalidate by model
  - DELETE ?signature=<hex> — invalidate single entry
  - DELETE ?staleMs=<ms> — invalidate entries older than N ms
  - DELETE (no params) — clear all (existing behavior)
- Sidebar: added Cache nav item (icon: cached)
- i18n: added cache + sidebar.cache keys for all 31 supported locales

No new dependencies. All functionality builds on existing semanticCache.ts,
cacheLayer.ts, and idempotencyLayer.ts modules.

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
2026-03-28 20:39:20 -03:00
Randi dd9de2efa9 fix: harden combo fallback and health checks (#704) 2026-03-28 20:39:16 -03:00
Randi f6b0811f78 [codex] fix provider limits ui (#718)
* fix provider limits ui

* restore remaining quota progress bars

* address provider limits review feedback
2026-03-28 20:39:06 -03:00
Randi eba9d854a9 fix model auto-sync startup and auth (#719) 2026-03-28 20:39:02 -03:00
Diego Rodrigues de Sa e Souza 437cf9bab0 chore(release): v3.1.10 — OmniRoute v3.1.9 remaining bug fixes sprint (#720)
Build Electron Desktop App / Validate version (push) Failing after 34s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-03-28 19:54:45 -03:00
Diego Rodrigues de Sa e Souza 9ffad1005e Merge pull request #713 from diegosouzapw/release/v3.1.9
Build Electron Desktop App / Validate version (push) Failing after 38s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
chore(release): v3.1.9 — schema coercion, tool sanitization, bug fixes
2026-03-28 17:37:08 -03:00
diegosouzapw 65edddd62e refactor(open-sse): remove unused imports from translator/index.ts
remove unused imports coerceToolSchemas and sanitizeToolDescriptions from translator/index.ts to satisfy lint and prevent unused import issues
2026-03-28 17:26:55 -03:00
diegosouzapw a7cdcd8b3a chore(release): v3.1.9 — schema coercion, tool sanitization, clearAllModels i18n, bug fixes #605 #709 #710 #711 2026-03-28 16:35:20 -03:00
diegosouzapw 3d6b85ed20 fix: update Windsurf test to match merged config notes 2026-03-28 16:31:46 -03:00
diegosouzapw 7abea2020c Merge feature-tests: schema coercion, tool sanitization, Codex auth export, enhanced test suite 2026-03-28 16:27:32 -03:00
diegosouzapw e16c34f0e3 Merge feat/clear-all-models-button: clearAllModels i18n translations for 30 languages 2026-03-28 16:19:46 -03:00
diegosouzapw 4bfda6a145 Merge fix/issue-605: strip proxy_ prefix in non-streaming Claude responses (#605, #592) 2026-03-28 16:17:06 -03:00
diegosouzapw 98470e8551 Merge fix/issue-711: provider max_tokens cap + upstream sync tasks (#711) 2026-03-28 16:08:12 -03:00
diegosouzapw df558ab8d6 Merge fix/issue-710: A2A TaskManager globalThis singleton + E2E auth (#710) 2026-03-28 16:07:34 -03:00
diegosouzapw c07372b58c fix: ensure output directory exists for system-info (#709) 2026-03-28 15:54:15 -03:00
diegosouzapw 00f59b95ae fix: protocol clients e2e dev mode singleton and auth (#710) 2026-03-28 15:52:29 -03:00
diegosouzapw 8915a7c2cd fix: add provider-specific max_tokens cap (#711) 2026-03-28 15:41:59 -03:00
diegosouzapw 8595964ab8 feat/fix: implement upstream sync tasks 1-7 2026-03-28 14:48:57 -03:00
diegosouzapw 922dae8546 feat: add Codex auth.json export and apply-local buttons for CLI integration
- Add codexAuthFile.ts utility: builds Codex auth.json payload from OAuth connection
  (id_token, access_token, refresh_token, account_id) with auto-refresh if expired
- Add POST /api/providers/[id]/codex-auth/export: downloads auth.json file
- Add POST /api/providers/[id]/codex-auth/apply-local: writes auth.json to local CLI path
- Add 'Apply auth' and 'Export auth' buttons to ConnectionRow (Codex provider only)
- Add i18n keys for en and pt-BR
2026-03-28 13:28:06 -03:00
diegosouzapw 69b3e23400 test(tests): introduce feature-tests suite and update coverage tooling
- add unit tests for API auth, display/error utilities, login bootstrap,
  model combo mappings, provider validation branches, and usage analytics
- add COVERAGE_PLAN.md and extend CONTRIBUTING.md with coverage notes and
  workflow guidance
- update package.json to adjust test:coverage thresholds and add coverage:report;
  include c8 as a devDependency
- introduce test scaffolding and ensure compatibility with existing test runners
- align tests with open-sse changes and improve overall test coverage planning
2026-03-28 12:58:31 -03:00
diegosouzapw 55325773dc feat(open-sse): add schema coercion and tool sanitization
- introduce open-sse/translator/helpers/schemaCoercion.ts to coerce
  numeric JSON Schema fields encoded as strings
- wire coerceToolSchemas and sanitizeToolDescriptions into translator
  pipeline; ensure tool descriptions are sanitized
- inject empty reasoning content for tool calls when target is OpenAI
  format
- update qwen base URL to DashScope-compatible endpoint
- extend antigravity static catalog with Gemini 3.1 pro preview models and
  update Gemini model specs with preview aliases
- implement call log max cap caching with TTL; expose invalidateCallLogsMaxCache
  and invalidate on settings PATCH
- add tests: call-log-cap.test.mjs and tool-request-sanitization.test.mjs;
  extend tests for Windsurf integration and gemini previews
- update CLI runtime and tools to include Windsurf as a guide-only tool
- add maxCallLogs to validation schemas (settings and updateSettings)
- add Czech README (README.cs.md) to repository
2026-03-28 12:33:13 -03:00
Diego Rodrigues de Sa e Souza cfb390936a Merge pull request #697 from diegosouzapw/fix/issue-667-opencode-zen-models
fix: add opencode-zen to PROVIDER_MODELS_CONFIG (#667)
2026-03-28 01:55:06 -03:00
diegosouzapw c5f344f333 fix: add opencode-zen to PROVIDER_MODELS_CONFIG (#667)
The 'Import from /models' button failed because opencode-zen was not
registered in PROVIDER_MODELS_CONFIG. The provider's API at
https://opencode.ai/zen/v1/models returns standard OpenAI-compatible
format and is now properly configured for model import.
2026-03-28 01:54:39 -03:00
diegosouzapw ba4b496306 Merge PR #666: Add Claude prompt cache logging and exclude cache reads
Includes fixes applied during review:
- Removed duplicate imports in chatCore.ts
- Fixed stray translatedBody argument (stream boolean bug)
- Fixed truncated test file
- Fixed usageExtractor cached_tokens fallback

Closes #688, Closes #640
2026-03-28 01:53:25 -03:00
diegosouzapw c48554589c fix: repair test failures from PR #666 changes
- Fix usageExtractor cached_tokens fallback for Responses API (use cache_read_input_tokens when input_tokens_details is absent)
- Fix truncated claude-native-passthrough-tools.test.mjs that caused parse error
2026-03-28 01:50:04 -03:00
Diego Rodrigues de Sa e Souza da0851e21d Merge pull request #690 from alper-han/feat/i18n-tr
Reviewed and approved via consolidated analysis. Turkish locale (31st language) follows existing i18n patterns perfectly. Registered in config.ts, generate-multilang.mjs, and full tr.json translation file.
2026-03-28 01:46:04 -03:00
Diego Rodrigues de Sa e Souza d2d05abac0 Merge pull request #693 from christopher-s/main
Reviewed and approved via consolidated analysis. GLM-5.1 addition and pricing corrections match official Z.AI pricing page. All 5 files follow existing patterns.
2026-03-28 01:45:52 -03:00
Diego Rodrigues de Sa e Souza de3e0423cc Merge pull request #696 from benjaminkitt/fix/input-stream-invalid-boolean
Reviewed and approved via consolidated analysis. Fix is surgical (1 line removed) with 122 lines of regression tests covering stream=true, stream=false and guard scenarios. Resolves #677.
2026-03-28 01:45:39 -03:00
Benjamin Kitt 8d742d7938 test: add regression tests for stream boolean in claude passthrough
Three tests covering the fixed bug where translateRequest received an
object instead of a boolean for the stream parameter:
- stream=true round-trip produces boolean true
- stream=false round-trip produces boolean false
- guard test documenting that passing an object as stream breaks typing

Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 22:38:34 -05:00
Benjamin Kitt 682fd550fa fix(core): remove extra arg in claude passthrough translateRequest call
The second translateRequest call in the claude->openai->claude passthrough
path had an extra `translatedBody` argument before `stream`, shifting all
parameters by one. This caused the `stream` field in the upstream request
to be set to an object instead of a boolean, producing:
  "stream: Input should be a valid boolean"

Co-Authored-By: Craft Agent <agents-noreply@craft.do>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 22:30:02 -05:00
Chris Staley abcf836a0c feat: add GLM-5.1 to GLM Coding provider, update GLM-5 pricing
- Add glm-5.1 model to GLM Coding provider with fitness scores
- Update glm-5 pricing to match Z.AI API page ($1/$3.2/$0.2)
- Set glm-5.1 pricing to $1.2/$5/$0.3 per Z.AI
- Remove glm-4-32b (deprecated, returns empty from upstream)
- Rename Z.AI provider display name from "Z.AI (GLM-5)" to "Z.AI"
- Update zai pricing section to match glm pricing
2026-03-27 16:23:44 -06:00
diegosouzapw b123fb2cc7 chore(release): bump version to v3.1.8 and global i18n sync
Build Electron Desktop App / Validate version (push) Failing after 36s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-03-27 18:08:16 -03:00
Diego Rodrigues de Sa e Souza 0da3621a68 Merge pull request #692 from diegosouzapw/fix/recent-bugs
fix: resolve issues 681, 684, 685
2026-03-27 18:04:03 -03:00
alper-han 8ed452d9ea feat: add Turkish translations 2026-03-27 22:28:21 +03:00
diegosouzapw f380d44697 fix(core): hidden models flag, antigravity streaming, and i18n translation sync (#681, #684, #685) 2026-03-27 16:17:28 -03:00
Chris 86d377a2f0 fix: remove id/type from tool_calls delta chunks in Responses API streaming (#683)
In OpenAI Chat Completions streaming format, the tool call id and type
should only appear on the first chunk (tool declaration). Subsequent
argument delta chunks should only include index and function.arguments.

Including id on every delta chunk caused openai-to-claude.ts to emit
a new content_block_start for each chunk, breaking Claude Code ACP
sessions with malformed Claude-format streams.

Fixes #682

Co-authored-by: Chris Staley <christopher.staley@protonmail.com>
2026-03-27 15:25:16 -03:00
cai kerui ae8d2ac2e1 Merge branch 'main' into codex/claude-cache-log-accounting 2026-03-27 17:25:38 +09:00
cai kerui 93beb068a3 Add Claude prompt cache logging and exclude cache reads 2026-03-27 15:14:54 +09:00
diegosouzapw 7e90b8b7be i18n: add clearAllModels translations for all 30 languages 2026-03-26 08:43:52 -03:00
diegosouzapw ed146fcf07 fix: strip proxy_ prefix in non-streaming Claude responses & fix LongCat validation (#605, #592)
- fix(translator): pass toolNameMap to translateNonStreamingResponse so Claude
  OAuth proxy_ prefix is correctly stripped from tool_use block names in
  non-streaming responses (was only stripped in streaming path)
- fix(validation): add LongCat specialty validator that probes /chat/completions
  directly, bypassing the /v1/models endpoint that LongCat does not expose (#592)
2026-03-25 08:11:35 -03:00
168 changed files with 63826 additions and 599 deletions
+9
View File
@@ -37,6 +37,13 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from release tag or input
id: version
run: |
@@ -59,6 +66,8 @@ jobs:
tags: |
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.IMAGE_NAME }}:latest
ghcr.io/diegosouzapw/omniroute:${{ steps.version.outputs.version }}
ghcr.io/diegosouzapw/omniroute:latest
cache-from: type=gha
cache-to: type=gha,mode=max
no-cache: false
+18
View File
@@ -105,3 +105,21 @@ jobs:
echo "✅ Published omniroute@$VERSION (tag: $TAG)"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to GitHub Packages
run: |
VERSION="${{ steps.resolve.outputs.version }}"
TAG="${{ steps.resolve.outputs.tag }}"
echo "Configuring for GitHub Packages..."
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc
npm pkg set name="@diegosouzapw/omniroute"
if [ "$TAG" = "latest" ]; then
npm publish --registry=https://npm.pkg.github.com || echo "⚠️ Version ${VERSION} might already be published on GitHub."
else
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" || echo "⚠️ Version ${VERSION} might already be published on GitHub."
fi
echo "✅ Action finished for GitHub Packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -112,6 +112,7 @@ app.log
# Backup directories
app.__qa_backup/
.app-build-backup-*/
# Production standalone build (created by scripts/prepublish.mjs)
# Conflicts with Next.js App Router detection in dev (root app/ shadows src/app/)
+103 -1
View File
@@ -2,7 +2,109 @@
## [Unreleased]
## [3.1.7] - 2026-03-27
---
## [3.2.2] — 2026-03-29
### ✨ New Features
- **Four-Stage Request Log Pipeline (#705)** — Refactored log persistence to save comprehensive payloads at four distinct pipeline stages: Client Request, Translated Provider Request, Provider Response, and Translated Client Response. Introduced `streamPayloadCollector` for robust SSE stream truncation and payload serialization.
### 🐛 Bug Fixes
- **Mobile UI Fixes (#659)** — Prevented table components on the dashboard from breaking the layout on narrow viewports by adding proper horizontal scrolling and overflow containment to `DashboardLayout`.
- **Claude Prompt Cache Fixes (#708)** — Ensured `cache_control` blocks in Claude-to-Claude fallback loops are faithfully preserved and passed safely back to Anthropic models.
- **Gemini Tool Definitions (#725)** — Fixed schema translation errors when declaring simple `object` parameter types for Gemini function calling.
## [3.2.1] — 2026-03-29
### ✨ New Features
- **Global Fallback Provider (#689)** — When all combo models are exhausted (502/503), OmniRoute now attempts a configurable global fallback model before returning the error. Set `globalFallbackModel` in settings to enable.
### 🐛 Bug Fixes
- **Fix #721** — Fixed context pinning bypass during tool-call responses. Non-streaming tagging used wrong JSON path (`json.messages``json.choices[0].message`). Streaming injection now triggers on `finish_reason` chunks for tool-call-only streams. `injectModelTag()` now appends synthetic pin messages for non-string content.
- **Fix #709** — Confirmed already fixed (v3.1.9) — `system-info.mjs` creates directories recursively. Closed.
- **Fix #707** — Confirmed already fixed (v3.1.9) — empty tool name sanitization in `chatCore.ts`. Closed.
### 🧪 Tests
- Added 6 unit tests for context pinning with tool-call responses (null content, array content, roundtrip, re-injection)
## [3.2.0] — 2026-03-28
### ✨ New Features
- **Cache Management UI** — Added a dedicated semantic caching dashboard at \`/dashboard/cache\` with targeted API invalidation and 31-language i18n support (PR #701 by @oyi77)
- **GLM Quota Tracking** — Added real-time usage and session quota tracking for the GLM Coding (Z.AI) provider (PR #698 by @christopher-s)
- **Detailed Log Payloads** — Wired full four-stage pipeline payload capturing (original, translated, provider-response, streamed-deltas) directly into the UI (PR #705 by @rdself)
### 🐛 Bug Fixes
- **Fix #708** — Prevented token bleeding for Claude Code users routing through OmniRoute by correctly preserving native \`cache_control\` headers during Claude-to-Claude passthrough (PR #708 by @tombii)
- **Fix #719** — Setup internal auth boundaries for \`ModelSyncScheduler\` to prevent unauthenticated daemon failures on startup (PR #719 by @rdself)
- **Fix #718** — Rebuilt badge rendering in Provider Limits UI preventing bad quota boundaries overlap (PR #718 by @rdself)
- **Fix #704** — Fixed Combo Fallbacks breaking on HTTP 400 content-policy errors preventing model-rotation dead-routing (PR #704 by @rdself)
### 🔒 Security & Dependencies
- Bumped \`path-to-regexp\` to \`8.4.0\` resolving dependabot vulnerabilities (PR #715)
## [3.1.10] — 2026-03-28
### 🐛 Bug Fixes
- **Fix #706** — Fixed icon fallback rendering caused by Tailwind V4 `font-sans` override by applying `!important` to `.material-symbols-outlined`.
- **Fix #703** — Fixed GitHub Copilot broken streams by enabling `responses` to `openai` format translation for any custom models leveraging `apiFormat: "responses"`.
- **Fix #702** — Replaced flat-rate usage tracking with accurate DB pricing calculations for both streaming and non-streaming responses.
- **Fix #716** — Cleaned up Claude tool-call translation state, correctly parsing streaming arguments and preventing OpenAI `tool_calls` chunks from repeating the `id` field.
## [3.1.9] — 2026-03-28
### ✨ New Features
- **Schema Coercion** — Auto-coerce string-encoded numeric JSON Schema constraints (e.g. `"minimum": "1"`) to proper types, preventing 400 errors from Cursor, Cline, and other clients sending malformed tool schemas.
- **Tool Description Sanitization** — Ensure tool descriptions are always strings; converts `null`, `undefined`, or numeric descriptions to empty strings before sending to providers.
- **Clear All Models Button** — Added i18n translations for the "Clear All Models" provider action across all 30 languages.
- **Codex Auth Export** — Added Codex `auth.json` export and apply-local buttons for seamless CLI integration.
- **Windsurf BYOK Notes** — Added official limitation warnings to the Windsurf CLI tool card documenting BYOK constraints.
### 🐛 Bug Fixes
- **Fix #709** — `system-info.mjs` no longer crashes when the output directory doesn't exist (added `mkdirSync` with recursive flag).
- **Fix #710** — A2A `TaskManager` singleton now uses `globalThis` to prevent state leakage across Next.js API route recompilations in dev mode. E2E test suite updated to handle 401 gracefully.
- **Fix #711** — Added provider-specific `max_tokens` cap enforcement for upstream requests.
- **Fix #605 / #592** — Strip `proxy_` prefix from tool names in non-streaming Claude responses; fixed LongCat validation URL.
- **Call Logs Max Cap** — Upgraded `getMaxCallLogs()` with caching layer, env var support (`CALL_LOGS_MAX`), and DB settings integration.
### 🧪 Tests
- Test suite expanded from 964 → 1027 tests (63 new tests)
- Added `schema-coercion.test.mjs` — 9 tests for numeric field coercion and tool description sanitization
- Added `t40-opencode-cli-tools-integration.test.mjs` — OpenCode/Windsurf CLI integration tests
- Enhanced feature-tests branch with comprehensive coverage tooling
### 📁 New Files
| File | Purpose |
| -------------------------------------------------------- | ----------------------------------------------------------- |
| `open-sse/translator/helpers/schemaCoercion.ts` | Schema coercion and tool description sanitization utilities |
| `tests/unit/schema-coercion.test.mjs` | Unit tests for schema coercion |
| `tests/unit/t40-opencode-cli-tools-integration.test.mjs` | CLI tool integration tests |
| `COVERAGE_PLAN.md` | Test coverage planning document |
### 🐛 Bug Fixes
- **Claude Prompt Caching Passthrough** — Fixed cache_control markers being stripped in Claude passthrough mode (Claude → OmniRoute → Claude), which caused Claude Code users to deplete their Anthropic API quota 5-10x faster than direct connections. OmniRoute now preserves client's cache_control markers when sourceFormat and targetFormat are both Claude, ensuring prompt caching works correctly and dramatically reducing token consumption.
## [3.1.8] - 2026-03-27
### 🐛 Bug Fixes & Features
- **Platform Core:** Implemented global state handling for Hidden Models & Combos preventing them from cluttering the catalog or leaking into connected MCP agents (#681).
- **Stability:** Patched streaming crashes related to the native Antigravity provider integration failing due to unhandled undefined state arrays (#684).
- **Localization Sync:** Deployed a fully overhauled `i18n` synchronizer detecting missing nested JSON properties and retro-fitting 30 locales sequentially (#685).## [3.1.7] - 2026-03-27
### 🐛 Bug Fixes
+8 -1
View File
@@ -114,6 +114,7 @@ npm run test:fixes # Fix verification tests
# With coverage
npm run test:coverage
npm run coverage:report
# E2E tests (requires Playwright)
npm run test:e2e
@@ -123,7 +124,13 @@ npm run lint
npm run check
```
Current test status: **368+ unit tests** covering:
Coverage notes:
- `npm run test:coverage` measures source coverage for the main unit test suite, excludes `tests/**`, and includes `open-sse/**`
- `npm run coverage:report` prints the detailed file-by-file report from the latest coverage run
- `npm run test:coverage:legacy` preserves the older metric for historical comparison
Current test status: **968+ unit tests** covering:
- Provider translators and format conversion
- Rate limiting, circuit breaker, and resilience
+166
View File
@@ -0,0 +1,166 @@
# Test Coverage Plan
Last updated: 2026-03-28
## Baseline
There are multiple coverage numbers depending on how the report is computed. For planning, only one of them is useful.
| Metric | Scope | Statements / Lines | Branches | Functions | Notes |
| -------------------- | ----------------------------------------------------- | -----------------: | -------: | --------: | --------------------------------------------------- |
| Legacy | Old `npm run test:coverage` | 79.42% | 75.15% | 67.94% | Inflated: counts test files and excludes `open-sse` |
| Diagnostic | Source-only, excluding tests and excluding `open-sse` | 68.16% | 63.55% | 64.06% | Useful only to isolate `src/**` |
| Recommended baseline | Source-only, excluding tests and including `open-sse` | 56.95% | 66.05% | 57.80% | This is the project-wide baseline to improve |
The recommended baseline is the number to optimize against.
## Rules
- Coverage targets apply to source files, not to `tests/**`.
- `open-sse/**` is part of the product and must remain in scope.
- New code should not reduce coverage in touched areas.
- Prefer testing behavior and branch outcomes over implementation details.
- Prefer temp SQLite databases and small fixtures over broad mocks for `src/lib/db/**`.
## Current command set
- `npm run test:coverage`
- Main source coverage gate for the unit test suite
- Generates `text-summary`, `html`, `json-summary`, and `lcov`
- `npm run coverage:report`
- Detailed file-by-file report from the latest run
- `npm run test:coverage:legacy`
- Historical comparison only
## Milestones
| Phase | Target | Focus |
| ------- | ---------------------: | ------------------------------------------------- |
| Phase 1 | 60% statements / lines | Quick wins and low-risk utility coverage |
| Phase 2 | 65% statements / lines | DB and route foundations |
| Phase 3 | 70% statements / lines | Provider validation and usage analytics |
| Phase 4 | 75% statements / lines | `open-sse` translators and helpers |
| Phase 5 | 80% statements / lines | `open-sse` handlers and executor branches |
| Phase 6 | 85% statements / lines | Harder edge cases, branch debt, regression suites |
| Phase 7 | 90% statements / lines | Final sweep, gap closure, strict ratchet |
Branches and functions should ratchet upward with each phase, but the primary hard target is statements / lines.
## Priority hotspots
These files or areas offer the best return for the next phases:
1. `open-sse/handlers`
- `chatCore.ts` at 7.57%
- Overall directory at 29.07%
2. `open-sse/translator/request`
- Overall directory at 36.39%
- Many translators are still near single-digit coverage
3. `open-sse/translator/response`
- Overall directory at 8.07%
4. `open-sse/executors`
- Overall directory at 36.62%
5. `src/lib/db`
- `models.ts` at 20.66%
- `registeredKeys.ts` at 34.46%
- `modelComboMappings.ts` at 36.25%
- `settings.ts` at 46.40%
- `webhooks.ts` at 33.33%
6. `src/lib/usage`
- `usageHistory.ts` at 21.12%
- `usageStats.ts` at 9.56%
- `costCalculator.ts` at 30.00%
7. `src/lib/providers`
- `validation.ts` at 41.16%
8. Low-risk utility and API files for early gains
- `src/shared/utils/upstreamError.ts`
- `src/shared/utils/apiAuth.ts`
- `src/lib/api/errorResponse.ts`
- `src/app/api/settings/require-login/route.ts`
- `src/app/api/providers/[id]/models/route.ts`
## Execution checklist
### Phase 1: 56.95% -> 60%
- [x] Fix coverage metric so it reflects source code instead of test files
- [x] Keep a legacy coverage script for comparison
- [x] Record the baseline and hotspots in-repo
- [ ] Add focused tests for low-risk utilities:
- `src/shared/utils/upstreamError.ts`
- `src/shared/utils/fetchTimeout.ts`
- `src/lib/api/errorResponse.ts`
- `src/shared/utils/apiAuth.ts`
- `src/lib/display/names.ts`
- [ ] Add route tests for:
- `src/app/api/settings/require-login/route.ts`
- `src/app/api/providers/[id]/models/route.ts`
### Phase 2: 60% -> 65%
- [ ] Add DB-backed tests for:
- `src/lib/db/modelComboMappings.ts`
- `src/lib/db/settings.ts`
- `src/lib/db/registeredKeys.ts`
- [ ] Cover branch behavior in:
- `src/lib/providers/validation.ts`
- `src/app/api/v1/embeddings/route.ts`
- `src/app/api/v1/moderations/route.ts`
### Phase 3: 65% -> 70%
- [ ] Add usage analytics tests for:
- `src/lib/usage/usageHistory.ts`
- `src/lib/usage/usageStats.ts`
- `src/lib/usage/costCalculator.ts`
- [ ] Expand route coverage for proxy management and settings branches
### Phase 4: 70% -> 75%
- [ ] Cover translator helpers and central translation paths:
- `open-sse/translator/index.ts`
- `open-sse/translator/helpers/*`
- `open-sse/translator/request/*`
- `open-sse/translator/response/*`
### Phase 5: 75% -> 80%
- [ ] Add handler-level tests for:
- `open-sse/handlers/chatCore.ts`
- `open-sse/handlers/responsesHandler.js`
- `open-sse/handlers/imageGeneration.js`
- `open-sse/handlers/embeddings.js`
- [ ] Add executor branch coverage for provider-specific auth, retries, and endpoint overrides
### Phase 6: 80% -> 85%
- [ ] Merge more edge-case suites into the main coverage path
- [ ] Increase function coverage for DB modules with weak constructor/helper coverage
- [ ] Close branch gaps in `settings.ts`, `registeredKeys.ts`, `validation.ts`, and translator helpers
### Phase 7: 85% -> 90%
- [ ] Treat the remaining low-coverage files as blockers
- [ ] Add regression tests for every uncovered production bug fixed during the push to 90%
- [ ] Raise the coverage gate in CI only after the local baseline is stable for at least two consecutive runs
## Ratchet policy
Update `npm run test:coverage` thresholds only after the project actually exceeds the next milestone with a comfortable buffer.
Recommended ratchet sequence:
1. 55/60/55
2. 60/62/58
3. 65/64/62
4. 70/66/66
5. 75/70/72
6. 80/75/78
7. 85/80/84
8. 90/85/88
Order is `statements-lines / branches / functions`.
## Known gap
The current coverage command measures the main Node unit suite and includes source reached from it, including `open-sse`. It does not yet merge Vitest coverage into a single unified report. That merge is worth doing later, but it is not a blocker for starting the 60% -> 80% climb.
+8 -1
View File
@@ -1,13 +1,17 @@
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
COPY scripts/postinstall.mjs ./scripts/postinstall.mjs
COPY scripts/native-binary-compat.mjs ./scripts/native-binary-compat.mjs
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY . ./
RUN mkdir -p /app/data && npm run build
RUN mkdir -p /app/data && npm run build -- --webpack
FROM node:22-bookworm-slim AS runner-base
WORKDIR /app
@@ -25,6 +29,9 @@ ENV NODE_OPTIONS="--max-old-space-size=256"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2081
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+1962
View File
File diff suppressed because it is too large Load Diff
+2073
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -876,6 +876,35 @@ docker compose --profile base up -d
docker compose --profile cli up -d
```
**Using Docker Compose with Caddy (HTTPS Auto-TLS):**
OmniRoute can be securely exposed using Caddy's automatic SSL provisioning. Ensure your domain's DNS A record points to your server's IP.
```yaml
services:
omniroute:
image: diegosouzapw/omniroute:latest
container_name: omniroute
restart: unless-stopped
volumes:
- omniroute-data:/app/data
environment:
- PORT=20128
- NEXT_PUBLIC_BASE_URL=https://your-domain.com
caddy:
image: caddy:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
command: caddy reverse-proxy --from https://your-domain.com --to http://omniroute:20128
volumes:
omniroute-data:
```
| Image | Tag | Size | Description |
| ------------------------ | -------- | ------ | --------------------- |
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2080
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+2079
View File
File diff suppressed because it is too large Load Diff
+2074
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -2,7 +2,7 @@
🌐 **Languages:** 🇺🇸 [English](ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](i18n/pt-BR/ARCHITECTURE.md) | 🇪🇸 [Español](i18n/es/ARCHITECTURE.md) | 🇫🇷 [Français](i18n/fr/ARCHITECTURE.md) | 🇮🇹 [Italiano](i18n/it/ARCHITECTURE.md) | 🇷🇺 [Русский](i18n/ru/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](i18n/zh-CN/ARCHITECTURE.md) | 🇩🇪 [Deutsch](i18n/de/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](i18n/in/ARCHITECTURE.md) | 🇹🇭 [ไทย](i18n/th/ARCHITECTURE.md) | 🇺🇦 [Українська](i18n/uk-UA/ARCHITECTURE.md) | 🇸🇦 [العربية](i18n/ar/ARCHITECTURE.md) | 🇯🇵 [日本語](i18n/ja/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](i18n/vi/ARCHITECTURE.md) | 🇧🇬 [Български](i18n/bg/ARCHITECTURE.md) | 🇩🇰 [Dansk](i18n/da/ARCHITECTURE.md) | 🇫🇮 [Suomi](i18n/fi/ARCHITECTURE.md) | 🇮🇱 [עברית](i18n/he/ARCHITECTURE.md) | 🇭🇺 [Magyar](i18n/hu/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](i18n/id/ARCHITECTURE.md) | 🇰🇷 [한국어](i18n/ko/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](i18n/ms/ARCHITECTURE.md) | 🇳🇱 [Nederlands](i18n/nl/ARCHITECTURE.md) | 🇳🇴 [Norsk](i18n/no/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](i18n/pt/ARCHITECTURE.md) | 🇷🇴 [Română](i18n/ro/ARCHITECTURE.md) | 🇵🇱 [Polski](i18n/pl/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](i18n/sk/ARCHITECTURE.md) | 🇸🇪 [Svenska](i18n/sv/ARCHITECTURE.md) | 🇵🇭 [Filipino](i18n/phi/ARCHITECTURE.md) | 🇨🇿 [Čeština](i18n/cs/ARCHITECTURE.md)
_Last updated: 2026-03-24_
_Last updated: 2026-03-28_
## Executive Summary
@@ -274,8 +274,9 @@ Domain State DB (SQLite):
## 5) Cloud Sync
- Scheduler init: `src/lib/initCloudSync.ts`, `src/shared/services/initializeCloudSync.ts`
- Scheduler init: `src/lib/initCloudSync.ts`, `src/shared/services/initializeCloudSync.ts`, `src/shared/services/modelSyncScheduler.ts`
- Periodic task: `src/shared/services/cloudSyncScheduler.ts`
- Periodic task: `src/shared/services/modelSyncScheduler.ts`
- Control route: `src/app/api/sync/cloud/route.ts`
## Request Lifecycle (`/v1/chat/completions`)
@@ -355,7 +356,7 @@ flowchart TD
Q -- No --> R[Return all unavailable]
```
Fallback decisions are driven by `open-sse/services/accountFallback.ts` using status codes and error-message heuristics.
Fallback decisions are driven by `open-sse/services/accountFallback.ts` using status codes and error-message heuristics. Combo routing adds one extra guard: provider-scoped 400s such as upstream content-block and role-validation failures are treated as model-local failures so later combo targets can still run.
## OAuth Onboarding and Token Refresh Lifecycle
@@ -755,10 +756,18 @@ Runtime visibility sources:
- console logs from `src/sse/utils/logger.ts`
- per-request usage aggregates in SQLite (`usage_history`, `call_logs`, `proxy_logs`)
- four-stage detailed payload captures in SQLite (`request_detail_logs`) when `settings.detailed_logs_enabled=true`
- textual request status log in `log.txt` (optional/compat)
- optional deep request/translation logs under `logs/` when `ENABLE_REQUEST_LOGS=true`
- dashboard usage endpoints (`/api/usage/*`) for UI consumption
Detailed request payload capture stores up to four JSON payload stages per routed call:
- raw request received from the client
- translated request actually sent upstream
- provider response reconstructed as JSON (including streamed event sequences when applicable)
- final client response returned by OmniRoute
## Security-Sensitive Boundaries
- JWT secret (`JWT_SECRET`) secures dashboard session cookie verification/signing
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.1.7
version: 3.2.2
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
+2
View File
@@ -18,6 +18,7 @@ const nextConfig = {
"thread-stream",
"better-sqlite3",
"keytar",
"wreq-js",
"zod",
"child_process",
"fs",
@@ -72,6 +73,7 @@ const nextConfig = {
const KNOWN_EXTERNALS = new Set([
"better-sqlite3",
"keytar",
"wreq-js",
"zod",
"pino",
"pino-pretty",
+9
View File
@@ -66,6 +66,15 @@ export const DEFAULT_MAX_TOKENS = 64000;
// Minimum max tokens for tool calling (to prevent truncated arguments)
export const DEFAULT_MIN_TOKENS = 32000;
export const PROVIDER_MAX_TOKENS: Record<string, number> = {
groq: 16384, // Groq strict per-model enforcement
openai: 16384, // GPT-4/4o standard
anthropic: 65536, // Claude models
gemini: 65536, // Gemini Studio
};
export const DEFAULT_PROVIDER_MAX_TOKENS = 32000;
// HTTP status codes
export const HTTP_STATUS = {
BAD_REQUEST: 400,
+2 -2
View File
@@ -291,7 +291,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
alias: "qw",
format: "openai",
executor: "default",
baseUrl: "https://portal.qwen.ai/v1/chat/completions",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
authType: "oauth",
authHeader: "bearer",
headers: {
@@ -626,6 +626,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
models: [
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-5-turbo", name: "GLM 5 Turbo" },
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
@@ -635,7 +636,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "glm-4.5v", name: "GLM 4.5V (Vision)" },
{ id: "glm-4.5", name: "GLM 4.5" },
{ id: "glm-4.5-air", name: "GLM 4.5 Air" },
{ id: "glm-4-32b", name: "GLM 4 32B" },
],
},
+421 -99
View File
@@ -14,10 +14,16 @@ 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 {
buildErrorBody,
createErrorResult,
parseUpstreamError,
formatProviderError,
} from "../utils/error.ts";
import { HTTP_STATUS, PROVIDER_MAX_TOKENS } from "../config/constants.ts";
import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { isDetailedLoggingEnabled, saveRequestDetailLog } from "@/lib/db/detailedLogs";
import { logAuditEvent } from "@/lib/compliance";
import { handleBypassRequest } from "../utils/bypassHandler.ts";
import {
@@ -26,13 +32,17 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getLoggedInputTokens, getLoggedOutputTokens } from "@/lib/usage/tokenAccounting";
import { recordCost } from "@/domain/costRules";
import { calculateCost } from "@/lib/usage/costCalculator";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
import {
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
getModelUpstreamExtraHeaders,
} from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
import {
parseCodexQuotaHeaders,
getCodexResetTime,
@@ -68,6 +78,8 @@ import {
EMERGENCY_FALLBACK_CONFIG,
} from "../services/emergencyFallback.ts";
import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
import { generateRequestId } from "@/shared/utils/requestId";
import { normalizePayloadForLog } from "@/lib/logPayloads";
export function shouldUseNativeCodexPassthrough({
provider,
@@ -130,6 +142,157 @@ function restoreClaudePassthroughToolNames(
};
}
function getHeaderValueCaseInsensitive(
headers: Record<string, unknown> | null | undefined,
targetName: string
) {
if (!headers || typeof headers !== "object") return null;
const lowered = targetName.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lowered && typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function buildClaudePromptCacheLogMeta(
targetFormat: string,
finalBody: Record<string, unknown> | null | undefined,
providerHeaders: Record<string, unknown> | null | undefined
) {
if (targetFormat !== FORMATS.CLAUDE || !finalBody || typeof finalBody !== "object") return null;
const describeCacheControl = (cacheControl: Record<string, unknown> | undefined, extra = {}) => ({
type:
cacheControl && typeof cacheControl.type === "string" && cacheControl.type.trim()
? cacheControl.type.trim()
: "ephemeral",
ttl:
cacheControl && typeof cacheControl.ttl === "string" && cacheControl.ttl.trim()
? cacheControl.ttl.trim()
: null,
...extra,
});
const systemBreakpoints = Array.isArray(finalBody.system)
? finalBody.system.flatMap((block, index) => {
if (!block || typeof block !== "object") return [];
const cacheControl =
block.cache_control && typeof block.cache_control === "object"
? block.cache_control
: null;
return cacheControl ? [describeCacheControl(cacheControl, { index })] : [];
})
: [];
const toolBreakpoints = Array.isArray(finalBody.tools)
? finalBody.tools.flatMap((tool, index) => {
if (!tool || typeof tool !== "object") return [];
const cacheControl =
tool.cache_control && typeof tool.cache_control === "object" ? tool.cache_control : null;
const name = typeof tool.name === "string" && tool.name.trim() ? tool.name.trim() : null;
return cacheControl ? [describeCacheControl(cacheControl, { index, name })] : [];
})
: [];
const messageBreakpoints = Array.isArray(finalBody.messages)
? finalBody.messages.flatMap((message, messageIndex) => {
if (!message || typeof message !== "object" || !Array.isArray(message.content)) return [];
const role =
typeof message.role === "string" && message.role.trim() ? message.role.trim() : "unknown";
return message.content.flatMap((block, contentIndex) => {
if (!block || typeof block !== "object") return [];
const cacheControl =
block.cache_control && typeof block.cache_control === "object"
? block.cache_control
: null;
if (!cacheControl) return [];
return [
describeCacheControl(cacheControl, {
messageIndex,
contentIndex,
role,
blockType:
typeof block.type === "string" && block.type.trim() ? block.type.trim() : "unknown",
}),
];
});
})
: [];
const totalBreakpoints =
systemBreakpoints.length + toolBreakpoints.length + messageBreakpoints.length;
const anthropicBeta = getHeaderValueCaseInsensitive(providerHeaders, "Anthropic-Beta");
if (totalBreakpoints === 0 && !anthropicBeta) return null;
return {
applied: totalBreakpoints > 0,
totalBreakpoints,
anthropicBeta,
systemBreakpoints,
toolBreakpoints,
messageBreakpoints,
};
}
function toPositiveNumber(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
}
function buildCacheUsageLogMeta(usage: Record<string, unknown> | null | undefined) {
if (!usage || typeof usage !== "object") return null;
const promptTokenDetails =
usage.prompt_tokens_details && typeof usage.prompt_tokens_details === "object"
? (usage.prompt_tokens_details as Record<string, unknown>)
: undefined;
const hasCacheFields =
"cache_read_input_tokens" in usage ||
"cached_tokens" in usage ||
"cache_creation_input_tokens" in usage ||
(!!promptTokenDetails &&
("cached_tokens" in promptTokenDetails || "cache_creation_tokens" in promptTokenDetails));
const cacheReadTokens = toPositiveNumber(
usage.cache_read_input_tokens ?? usage.cached_tokens ?? promptTokenDetails?.cached_tokens
);
const cacheCreationTokens = toPositiveNumber(
usage.cache_creation_input_tokens ?? promptTokenDetails?.cache_creation_tokens
);
if (!hasCacheFields) return null;
return {
cacheReadTokens,
cacheCreationTokens,
};
}
function attachLogMeta(
payload: Record<string, unknown> | null | undefined,
meta: Record<string, unknown> | null | undefined
) {
if (!meta || typeof meta !== "object") return payload;
const compactMeta = Object.fromEntries(
Object.entries(meta).filter(([, value]) => value !== null && value !== undefined)
);
if (Object.keys(compactMeta).length === 0) return payload;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return { _omniroute: compactMeta, _payload: payload ?? null };
}
const existing =
payload._omniroute &&
typeof payload._omniroute === "object" &&
!Array.isArray(payload._omniroute)
? payload._omniroute
: {};
return {
...payload,
_omniroute: {
...existing,
...compactMeta,
},
};
}
/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
@@ -236,7 +399,8 @@ export async function handleChatCore({
credentials.providerSpecificData = nextProviderData;
} catch (err) {
log?.debug?.("CODEX", `Failed to persist codex quota state: ${err?.message || err}`);
const errMessage = err instanceof Error ? err.message : String(err);
log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`);
}
};
@@ -331,6 +495,88 @@ export async function handleChatCore({
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
const noLogEnabled = apiKeyInfo?.noLog === true;
const detailedLoggingEnabled = !noLogEnabled && (await isDetailedLoggingEnabled());
const persistAttemptLogs = ({
status,
tokens,
responseBody,
error,
providerRequest,
providerResponse,
clientResponse,
claudeCacheMeta,
claudeCacheUsageMeta,
}: {
status: number;
tokens?: unknown;
responseBody?: unknown;
error?: string | null;
providerRequest?: unknown;
providerResponse?: unknown;
clientResponse?: unknown;
claudeCacheMeta?: any;
claudeCacheUsageMeta?: any;
}) => {
const callLogId = generateRequestId();
saveCallLog({
id: callLogId,
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
tokens: tokens || {},
requestBody: attachLogMeta(body, {
claudePromptCache: claudeCacheMeta,
}),
responseBody: attachLogMeta(responseBody ?? undefined, {
claudePromptCache: claudeCacheMeta
? {
applied: claudeCacheMeta.applied,
totalBreakpoints: claudeCacheMeta.totalBreakpoints,
anthropicBeta: claudeCacheMeta.anthropicBeta,
}
: null,
claudePromptCacheUsage: claudeCacheUsageMeta,
}),
error: error || null,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: noLogEnabled,
}).catch(() => {});
if (!detailedLoggingEnabled) {
return;
}
try {
saveRequestDetailLog({
call_log_id: callLogId,
client_request: clientRawRequest?.body ?? body,
translated_request: providerRequest ?? null,
provider_response: providerResponse ?? null,
client_response: clientResponse ?? null,
provider,
model,
source_format: sourceFormat,
target_format: targetFormat,
duration_ms: Date.now() - startTime,
api_key_id: apiKeyInfo?.id || null,
no_log: noLogEnabled,
});
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
log?.debug?.("DETAIL_LOG", `Failed to save detailed log: ${errMessage}`);
}
};
// Primary path: merge client model id + alias target so config on either key applies; resolved
// id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next))
@@ -462,7 +708,6 @@ export async function handleChatCore({
FORMATS.CLAUDE,
model,
{ ...translatedBody, _disableToolPrefix: true },
translatedBody,
stream,
credentials,
provider,
@@ -642,6 +887,22 @@ export async function handleChatCore({
}
}
// Provider-specific max_tokens caps (#711)
// Some providers reject requests when max_tokens exceeds their API limit.
// Cap before sending to avoid upstream HTTP 400 errors.
const providerCap = PROVIDER_MAX_TOKENS[provider];
if (providerCap) {
for (const field of ["max_tokens", "max_completion_tokens"] as const) {
if (typeof translatedBody[field] === "number" && translatedBody[field] > providerCap) {
log?.debug?.(
"PARAMS",
`Capping ${field} from ${translatedBody[field]} to ${providerCap} for ${provider}`
);
translatedBody[field] = providerCap;
}
}
}
// Get executor for this provider
const executor = getExecutor(provider);
const getExecutionCredentials = () =>
@@ -721,6 +982,7 @@ export async function handleChatCore({
let providerUrl;
let providerHeaders;
let finalBody;
let claudePromptCacheLogMeta = null;
try {
const result = await executeProviderRequest(effectiveModel, true);
@@ -729,6 +991,11 @@ export async function handleChatCore({
providerUrl = result.url;
providerHeaders = result.headers;
finalBody = result.transformedBody;
claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
targetFormat,
finalBody,
providerHeaders
);
// Log target request (final request to provider)
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
@@ -743,38 +1010,34 @@ export async function handleChatCore({
);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false);
const failureStatus = error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY;
const failureMessage =
error.name === "AbortError"
? "Request aborted"
: formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
appendRequestLog({
model,
provider,
connectionId,
status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
requestBody: body,
error: error.message,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: apiKeyInfo?.noLog === true,
status: `FAILED ${failureStatus}`,
}).catch(() => {});
persistAttemptLogs({
status: failureStatus,
error: failureMessage,
providerRequest: finalBody || translatedBody,
clientResponse: buildErrorBody(failureStatus, failureMessage),
claudeCacheMeta: claudePromptCacheLogMeta,
});
if (error.name === "AbortError") {
streamController.handleError(error);
return createErrorResult(499, "Request aborted");
}
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, error?.name || "upstream_error");
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
persistFailureUsage(
HTTP_STATUS.BAD_GATEWAY,
error instanceof Error && error.name ? error.name : "upstream_error"
);
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, failureMessage);
}
// Handle 401/403 - try token refresh using executor
@@ -820,8 +1083,11 @@ export async function handleChatCore({
if (retryResult.response.ok) {
providerResponse = retryResult.response;
providerUrl = retryResult.url;
providerHeaders = retryResult.headers;
finalBody = retryResult.transformedBody;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
}
} catch (retryError) {
} catch {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`);
}
} else {
@@ -834,10 +1100,12 @@ export async function handleChatCore({
// Check provider response - return error info for fallback handling
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false);
const { statusCode, message, retryAfterMs } = await parseUpstreamError(
providerResponse,
provider
);
const {
statusCode,
message,
retryAfterMs,
responseBody: upstreamErrorBody,
} = await parseUpstreamError(providerResponse, provider);
// T06/T10/T36: classify provider errors and persist terminal account states.
const errorType = classifyProviderError(statusCode, message);
@@ -889,24 +1157,7 @@ export async function handleChatCore({
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(
() => {}
);
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: statusCode,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
requestBody: body,
error: message,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: apiKeyInfo?.noLog === true,
}).catch(() => {});
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
@@ -918,6 +1169,12 @@ export async function handleChatCore({
// Log error with full request body for debugging
reqLogger.logError(new Error(message), finalBody || translatedBody);
reqLogger.logProviderResponse(
providerResponse.status,
providerResponse.statusText,
providerResponse.headers,
upstreamErrorBody
);
// Update rate limiter from error response headers
updateFromHeaders(provider, connectionId, providerResponse.headers, statusCode, model);
@@ -941,24 +1198,53 @@ export async function handleChatCore({
providerUrl = fallbackResult.url;
providerHeaders = fallbackResult.headers;
finalBody = fallbackResult.transformedBody;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
// Continue processing with the fallback response — skip error return
log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`);
// Jump to streaming/non-streaming handling below
// We fall through by NOT returning here
} else {
// Fallback also failed — return original error
persistAttemptLogs({
status: statusCode,
error: errMsg,
providerRequest: finalBody || translatedBody,
providerResponse: upstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} catch {
persistAttemptLogs({
status: statusCode,
error: errMsg,
providerRequest: finalBody || translatedBody,
providerResponse: upstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else {
persistAttemptLogs({
status: statusCode,
error: errMsg,
providerRequest: finalBody || translatedBody,
providerResponse: upstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
});
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else {
persistAttemptLogs({
status: statusCode,
error: errMsg,
providerRequest: finalBody || translatedBody,
providerResponse: upstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
});
persistFailureUsage(statusCode, `upstream_${statusCode}`);
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
@@ -1003,6 +1289,10 @@ export async function handleChatCore({
});
if (fbResult.response.ok) {
providerResponse = fbResult.response;
providerUrl = fbResult.url;
providerHeaders = fbResult.headers;
finalBody = fbResult.transformedBody;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
log?.info?.(
"EMERGENCY_FALLBACK",
`Serving ${fbDecision.provider}/${fbDecision.model} as budget fallback for ${provider}/${model}`
@@ -1015,7 +1305,8 @@ export async function handleChatCore({
);
}
} catch (fbErr) {
log?.warn?.("EMERGENCY_FALLBACK", `Emergency fallback error: ${fbErr?.message}`);
const errMessage = fbErr instanceof Error ? fbErr.message : String(fbErr);
log?.warn?.("EMERGENCY_FALLBACK", `Emergency fallback error: ${errMessage}`);
}
}
}
@@ -1028,6 +1319,7 @@ export async function handleChatCore({
const contentType = (providerResponse.headers.get("content-type") || "").toLowerCase();
let responseBody;
const rawBody = await providerResponse.text();
const normalizedProviderPayload = normalizePayloadForLog(rawBody);
const looksLikeSSE =
contentType.includes("text/event-stream") || /(^|\n)\s*(event|data):/m.test(rawBody);
@@ -1045,11 +1337,16 @@ export async function handleChatCore({
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
const invalidSseMessage = "Invalid SSE response for non-streaming request";
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
error: invalidSseMessage,
providerRequest: finalBody || translatedBody,
providerResponse: normalizedProviderPayload,
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage),
});
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload");
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
"Invalid SSE response for non-streaming request"
);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage);
}
responseBody = parsedFromSSE;
@@ -1063,14 +1360,34 @@ export async function handleChatCore({
connectionId,
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
}).catch(() => {});
const invalidJsonMessage = "Invalid JSON response from provider";
persistAttemptLogs({
status: HTTP_STATUS.BAD_GATEWAY,
error: invalidJsonMessage,
providerRequest: finalBody || translatedBody,
providerResponse: normalizedProviderPayload,
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage),
});
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload");
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid JSON response from provider");
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage);
}
}
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) {
responseBody = restoreClaudePassthroughToolNames(responseBody, toolNameMap);
}
reqLogger.logProviderResponse(
providerResponse.status,
providerResponse.statusText,
providerResponse.headers,
looksLikeSSE
? {
_streamed: true,
_format: "sse-json",
summary: responseBody,
}
: responseBody
);
// Notify success - caller can clear error status if needed
if (onRequestSuccess) {
@@ -1084,27 +1401,9 @@ export async function handleChatCore({
);
// Save structured call log with full payloads
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
status: 200,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
tokens: usage,
requestBody: body,
responseBody,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: apiKeyInfo?.noLog === true,
}).catch(() => {});
const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
if (usage && typeof usage === "object") {
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${getLoggedInputTokens(usage)} | out=${getLoggedOutputTokens(usage)}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
saveRequestUsage({
@@ -1125,6 +1424,11 @@ export async function handleChatCore({
});
}
if (apiKeyInfo?.id && usage) {
const estimatedCost = await calculateCost(provider, model, usage);
if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost);
}
// Translate response to client's expected format (usually OpenAI)
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
let translatedResponse = needsTranslation(targetFormat, sourceFormat)
@@ -1190,6 +1494,23 @@ export async function handleChatCore({
// ── Phase 9.2: Save for idempotency ──
saveIdempotency(idempotencyKey, translatedResponse, 200);
reqLogger.logConvertedResponse(translatedResponse);
persistAttemptLogs({
status: 200,
tokens: usage,
responseBody,
providerRequest: finalBody || translatedBody,
providerResponse: looksLikeSSE
? {
_streamed: true,
_format: "sse-json",
summary: responseBody,
}
: responseBody,
clientResponse: translatedResponse,
claudeCacheMeta: claudePromptCacheLogMeta,
claudeCacheUsageMeta: cacheUsageLogMeta,
});
return {
success: true,
@@ -1225,42 +1546,43 @@ export async function handleChatCore({
status: streamStatus,
usage: streamUsage,
responseBody: streamResponseBody,
providerPayload,
clientPayload,
}) => {
saveCallLog({
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
const cacheUsageLogMeta = buildCacheUsageLogMeta(streamUsage);
persistAttemptLogs({
status: streamStatus || 200,
model,
requestedModel,
provider,
connectionId,
duration: Date.now() - startTime,
tokens: streamUsage || {},
requestBody: body,
responseBody: streamResponseBody ?? undefined,
sourceFormat,
targetFormat,
comboName,
apiKeyId: apiKeyInfo?.id || null,
apiKeyName: apiKeyInfo?.name || null,
noLog: apiKeyInfo?.noLog === true,
}).catch(() => {});
providerRequest: finalBody || translatedBody,
providerResponse: providerPayload,
clientResponse: clientPayload ?? streamResponseBody ?? undefined,
claudeCacheMeta: claudePromptCacheLogMeta,
claudeCacheUsageMeta: cacheUsageLogMeta,
});
if (apiKeyInfo?.id && streamUsage) {
calculateCost(provider, model, streamUsage)
.then((estimatedCost) => {
if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost);
})
.catch(() => {});
}
};
// For Codex provider, translate response from openai-responses to openai (Chat Completions) format
// For providers using Responses API format, translate stream back to openai (Chat Completions) format
// UNLESS client is Droid CLI which expects openai-responses format back
const isDroidCLI =
userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
const needsCodexTranslation =
provider === "codex" &&
const needsResponsesTranslation =
targetFormat === FORMATS.OPENAI_RESPONSES &&
sourceFormat === FORMATS.OPENAI &&
!isResponsesEndpoint &&
!isDroidCLI;
if (needsCodexTranslation) {
// Codex returns openai-responses, translate to openai (Chat Completions) that clients expect
log?.debug?.("STREAM", `Codex translation mode: openai-responses → openai`);
if (needsResponsesTranslation) {
// Provider returns openai-responses, translate to openai (Chat Completions) that clients expect
log?.debug?.("STREAM", `Responses translation mode: openai-responses → openai`);
transformStream = createSSETransformStreamWithLogger(
"openai-responses",
"openai",
+4 -1
View File
@@ -29,7 +29,10 @@ export function extractUsageFromResponse(responseBody, provider) {
return {
prompt_tokens: responsesUsage.input_tokens || 0,
completion_tokens: responsesUsage.output_tokens || 0,
cached_tokens: responsesUsage.cache_read_input_tokens,
cache_read_input_tokens: responsesUsage.cache_read_input_tokens,
cached_tokens:
responsesUsage.input_tokens_details?.cached_tokens ??
responsesUsage.cache_read_input_tokens,
cache_creation_input_tokens: responsesUsage.cache_creation_input_tokens,
reasoning_tokens:
responsesUsage.reasoning_tokens || responsesUsage.output_tokens_details?.reasoning_tokens,
+1 -1
View File
@@ -439,7 +439,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
if (args.provider && !args.capability) {
// Use direct provider fetch to get real-time API status
path = `/api/providers/${encodeURIComponent(args.provider)}/models`;
path = `/api/providers/${encodeURIComponent(args.provider)}/models?excludeHidden=true`;
isProviderSpecific = true;
} else {
const params = new URLSearchParams();
+4 -1
View File
@@ -36,7 +36,8 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
"grok-3": 0.8,
// Kimi K2.5 — agentic with tool calling, good at code tasks
"kimi-k2": 0.82,
// GLM-5 — Z.AI model with 128k output
// GLM-5.1 / GLM-5 — Z.AI reasoning models, 200K context / 128k output
"glm-5.1": 0.78,
"glm-5": 0.78,
// MiniMax M2.5 — reasoning support helps complex code
"minimax-m2.5": 0.75,
@@ -78,6 +79,7 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
"deepseek-r1": 0.88,
"deepseek-chat": 0.8,
"kimi-k2": 0.82, // Kimi K2.5 agentic — good for analysis
"glm-5.1": 0.82, // GLM-5.1 free reasoning, 200K context for long analysis
"glm-5": 0.78, // GLM-5 with 128k output for long analysis
"minimax-m2.5": 0.76,
},
@@ -114,6 +116,7 @@ const FITNESS_TABLE: Record<string, Record<string, number>> = {
"grok-4": 0.74,
"grok-3": 0.73,
"kimi-k2": 0.76, // agentic multi-step tasks
"glm-5.1": 0.75,
"glm-5": 0.7,
"minimax-m2.5": 0.7,
},
+80 -9
View File
@@ -20,6 +20,15 @@ import { supportsToolCalling } from "./modelCapabilities.ts";
// Status codes that should mark semaphore + record circuit breaker failures
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [
/\bprohibited_content\b/i,
/request blocked by .*api/i,
/provided message roles? is not valid/i,
/unsupported .*message role/i,
/no such tool available/i,
/unsupported content part type/i,
/tool(?:_call|_use)? .* not (?:available|found)/i,
];
const MAX_COMBO_DEPTH = 3;
@@ -258,6 +267,12 @@ function extractPromptForIntent(body) {
return "";
}
export function shouldFallbackComboBadRequest(status, errorText) {
if (status !== 400 || !errorText) return false;
const message = String(errorText);
return COMBO_BAD_REQUEST_FALLBACK_PATTERNS.some((pattern) => pattern.test(message));
}
function mapIntentToTaskType(intent) {
switch (intent) {
case "code":
@@ -449,14 +464,23 @@ export async function handleComboChat({
const res = await handleSingleModel(b, modelStr);
if (!res.ok) return res;
// Non-streaming: inject tag into JSON response (existing logic)
// Non-streaming: inject tag into JSON response
// Fix #721: Use OpenAI choices format (json.choices[0].message) not json.messages
if (!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 }), {
const choice = json?.choices?.[0];
if (choice?.message) {
// Wrap single message in array for injectModelTag, then unwrap
const tagged = injectModelTag([choice.message], modelStr);
// If the message had tool_calls but no string content, injectModelTag
// appends a synthetic assistant message — use the last one
const taggedMsg = tagged[tagged.length - 1];
const updatedJson = {
...json,
choices: [{ ...choice, message: taggedMsg }, ...(json.choices?.slice(1) || [])],
};
return new Response(JSON.stringify(updatedJson), {
status: res.status,
headers: res.headers,
});
@@ -487,8 +511,9 @@ export async function handleComboChat({
const text = decoder.decode(chunk, { stream: true });
// Look for the first SSE data line with non-empty content
// Pattern: "content":"<non-empty>" — we inject tag at the start
// Fix #721: Look for either non-empty content OR tool_calls in the
// SSE data. Tool-call-only responses have content:null, so we inject
// the tag when we see a finish_reason approaching, or on first content.
const contentMatch = text.match(/"content":"([^"]+)/);
if (contentMatch) {
// Inject tag at the beginning of the first content value
@@ -501,6 +526,27 @@ export async function handleComboChat({
return;
}
// Fix #721: For tool-call-only streams, inject the tag when we see
// the finish_reason chunk (before it reaches the client SDK which
// would close the connection). This ensures the tag roundtrips
// through the conversation history even when there's no text content.
if (text.includes('"finish_reason"') && !text.includes('"finish_reason":null')) {
// Inject a content chunk with the tag just before this finish chunk
const tagChunk = `data: ${JSON.stringify({
choices: [
{
delta: { content: tagContent },
index: 0,
finish_reason: null,
},
],
})}\n\n`;
tagInjected = true;
controller.enqueue(encoder.encode(tagChunk));
controller.enqueue(chunk);
return;
}
// No content yet — passthrough
controller.enqueue(chunk);
},
@@ -568,6 +614,15 @@ export async function handleComboChat({
: handleSingleModel;
// ─────────────────────────────────────────────────────────────────────────
// Route to pinned model if context caching specifies one (Fix #679)
if (pinnedModel) {
log.info(
"COMBO",
`Bypassing strategy — routing directly to pinned context model: ${pinnedModel}`
);
return handleSingleModelWrapped(body, pinnedModel);
}
// Route to round-robin handler if strategy matches
if (strategy === "round-robin") {
return handleRoundRobinCombo({
@@ -881,17 +936,25 @@ export async function handleComboChat({
provider,
result.headers
);
const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText);
// Record failure in circuit breaker for transient errors
if (TRANSIENT_FOR_BREAKER.includes(result.status)) {
breaker._onFailure();
}
if (!shouldFallback) {
if (!shouldFallback && !comboBadRequestFallback) {
log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status });
return result;
}
if (comboBadRequestFallback) {
log.info(
"COMBO",
`Treating provider-scoped 400 from ${modelStr} as model-local failure; trying next combo target`
);
}
// Check if this is a transient error worth retrying on same model
const isTransient = [408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient) {
@@ -1137,6 +1200,7 @@ async function handleRoundRobinCombo({
provider,
result.headers
);
const comboBadRequestFallback = shouldFallbackComboBadRequest(result.status, errorText);
// Transient errors → mark in semaphore AND record circuit breaker failure
if (TRANSIENT_FOR_BREAKER.includes(result.status) && cooldownMs > 0) {
@@ -1148,11 +1212,18 @@ async function handleRoundRobinCombo({
);
}
if (!shouldFallback) {
if (!shouldFallback && !comboBadRequestFallback) {
log.warn("COMBO-RR", `${modelStr} failed (no fallback)`, { status: result.status });
return result;
}
if (comboBadRequestFallback) {
log.info(
"COMBO-RR",
`Treating provider-scoped 400 from ${modelStr} as model-local failure; trying next model`
);
}
// Transient error → retry same model
const isTransient = [408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient) {
+11 -1
View File
@@ -67,7 +67,17 @@ export function injectModelTag(messages: Message[], providerModel: string): Mess
}
const msg = cleaned[lastAssistantIdx];
if (typeof msg.content !== "string") return cleaned;
// Fix #721: Handle messages where content is not a string (tool_calls responses).
// In this case, append a synthetic assistant message with the tag so the pin
// roundtrips through the conversation history.
if (typeof msg.content !== "string") {
// If the message has tool_calls but no string content, append a new assistant
// message with the tag rather than silently failing.
return [
...cleaned,
{ role: "assistant", content: `\n<omniModel>${providerModel}</omniModel>` },
];
}
const tagged = [...cleaned];
tagged[lastAssistantIdx] = {
+56 -1
View File
@@ -100,13 +100,66 @@ function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota
return quota.total > 0 || quota.remainingPercentage !== undefined;
}
// GLM (Z.AI) quota API config
const GLM_QUOTA_URLS: Record<string, string> = {
international: "https://api.z.ai/api/monitor/usage/quota/limit",
china: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
};
async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string, unknown>) {
const region = providerSpecificData?.apiRegion || "international";
const quotaUrl = GLM_QUOTA_URLS[region] || GLM_QUOTA_URLS.international;
const res = await fetch(quotaUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (!res.ok) {
if (res.status === 401) throw new Error("Invalid API key");
throw new Error(`GLM quota API error (${res.status})`);
}
const json = await res.json();
const data = toRecord(json.data);
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
const quotas: Record<string, UsageQuota> = {};
for (const limit of limits) {
const src = toRecord(limit);
if (src.type !== "TOKENS_LIMIT") continue;
const usedPercent = toNumber(src.percentage, 0);
const resetMs = toNumber(src.nextResetTime, 0);
const remaining = Math.max(0, 100 - usedPercent);
quotas["session"] = {
used: usedPercent,
total: 100,
remaining,
remainingPercentage: remaining,
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
unlimited: false,
};
}
const levelRaw = typeof data.level === "string" ? data.level : "";
const plan = levelRaw
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
: "Unknown";
return { plan, quotas };
}
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
* @returns {Promise<unknown>} Usage data with quotas
*/
export async function getUsageForProvider(connection) {
const { provider, accessToken, providerSpecificData } = connection;
const { provider, accessToken, apiKey, providerSpecificData } = connection;
switch (provider) {
case "github":
@@ -127,6 +180,8 @@ export async function getUsageForProvider(connection) {
return await getQwenUsage(accessToken, providerSpecificData);
case "iflow":
return await getIflowUsage(accessToken);
case "glm":
return await getGlmUsage(apiKey, providerSpecificData);
default:
return { message: `Usage API not implemented for ${provider}` };
}
+46 -9
View File
@@ -86,14 +86,33 @@ export function fixToolUseOrdering(messages) {
return merged;
}
function ensureMessageContentArray(msg) {
if (Array.isArray(msg?.content)) return msg.content;
if (typeof msg?.content === "string" && msg.content.trim()) {
msg.content = [{ type: "text", text: msg.content }];
return msg.content;
}
return [];
}
function markMessageCacheControl(msg, ttl) {
const content = ensureMessageContentArray(msg);
if (content.length === 0) return false;
const lastIndex = content.length - 1;
content[lastIndex].cache_control =
ttl !== undefined ? { type: "ephemeral", ttl } : { type: "ephemeral" };
return true;
}
// Prepare request for Claude format endpoints
// - Cleanup cache_control
// - Cleanup cache_control (unless preserveCacheControl=true for passthrough)
// - Filter empty messages
// - Add thinking block for Anthropic endpoint (provider === "claude")
// - Fix tool_use/tool_result ordering
export function prepareClaudeRequest(body, provider = null) {
export function prepareClaudeRequest(body, provider = null, preserveCacheControl = false) {
// 1. System: remove all cache_control, add only to last block with ttl 1h
if (body.system && Array.isArray(body.system)) {
// In passthrough mode, preserve existing cache_control markers
if (body.system && Array.isArray(body.system) && !preserveCacheControl) {
body.system = body.system.map((block, i) => {
const { cache_control, ...rest } = block;
if (i === body.system.length - 1) {
@@ -109,11 +128,12 @@ export function prepareClaudeRequest(body, provider = null) {
let filtered = [];
// Pass 1: remove cache_control + filter empty messages
// In passthrough mode, preserve existing cache_control markers
for (let i = 0; i < len; i++) {
const msg = body.messages[i];
// Remove cache_control from content blocks
if (Array.isArray(msg.content)) {
// Remove cache_control from content blocks (skip in passthrough mode)
if (Array.isArray(msg.content) && !preserveCacheControl) {
for (const block of msg.content) {
delete block.cache_control;
}
@@ -156,15 +176,31 @@ export function prepareClaudeRequest(body, provider = null) {
const lastMessageIsUser = lastMessage?.role === "user";
const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser;
// Claude Code-style prompt caching:
// - cache the second-to-last user turn for conversation reuse
// - cache the last assistant turn so the next user turn can reuse it
// Skip in passthrough mode to preserve client's cache_control markers
if (!preserveCacheControl) {
const userMessageIndexes = filtered.reduce((indexes, msg, index) => {
if (msg?.role === "user") indexes.push(index);
return indexes;
}, []);
const secondToLastUserIndex =
userMessageIndexes.length >= 2 ? userMessageIndexes[userMessageIndexes.length - 2] : -1;
if (secondToLastUserIndex >= 0) {
markMessageCacheControl(filtered[secondToLastUserIndex]);
}
}
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
let lastAssistantProcessed = false;
for (let i = filtered.length - 1; i >= 0; i--) {
const msg = filtered[i];
if (msg.role === "assistant" && Array.isArray(msg.content)) {
if (msg.role === "assistant" && Array.isArray(ensureMessageContentArray(msg))) {
// Add cache_control to last block of first (from end) assistant with content
if (!lastAssistantProcessed && msg.content.length > 0) {
msg.content[msg.content.length - 1].cache_control = { type: "ephemeral" };
// Skip in passthrough mode to preserve client's cache_control markers
if (!preserveCacheControl && !lastAssistantProcessed && markMessageCacheControl(msg)) {
lastAssistantProcessed = true;
}
@@ -197,7 +233,8 @@ export function prepareClaudeRequest(body, provider = null) {
// 3. Tools: remove all cache_control, add only to last non-deferred tool with ttl 1h
// Tools with defer_loading=true cannot have cache_control (API rejects it)
if (body.tools && Array.isArray(body.tools)) {
// In passthrough mode, preserve existing cache_control markers
if (body.tools && Array.isArray(body.tools) && !preserveCacheControl) {
body.tools = body.tools.map((tool) => {
const { cache_control, ...rest } = tool;
return rest;
@@ -0,0 +1,207 @@
/**
* Shared sanitizers for tool payloads that arrive from IDEs/SDKs with
* JSON Schema numeric constraints encoded as strings or invalid descriptions.
*/
type JsonRecord = Record<string, unknown>;
const NUMERIC_SCHEMA_FIELDS = [
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"minItems",
"maxItems",
"minProperties",
"maxProperties",
"multipleOf",
] as const;
function isPlainObject(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function coerceNumericString(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
if (trimmed.length === 0) return value;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : value;
}
function mapRecordValues(record: JsonRecord): JsonRecord {
return Object.fromEntries(
Object.entries(record).map(([key, value]) => [key, coerceSchemaNumericFields(value)])
);
}
function sanitizeDescriptionValue(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (value === null) return "";
return typeof value === "string" ? value : String(value);
}
export function coerceSchemaNumericFields(schema: unknown): unknown {
if (Array.isArray(schema)) {
return schema.map((entry) => coerceSchemaNumericFields(entry));
}
if (!isPlainObject(schema)) return schema;
const result: JsonRecord = { ...schema };
for (const field of NUMERIC_SCHEMA_FIELDS) {
if (field in result) {
result[field] = coerceNumericString(result[field]);
}
}
if (isPlainObject(result.properties)) {
result.properties = mapRecordValues(result.properties);
}
if (isPlainObject(result.patternProperties)) {
result.patternProperties = mapRecordValues(result.patternProperties);
}
if (isPlainObject(result.definitions)) {
result.definitions = mapRecordValues(result.definitions);
}
if (isPlainObject(result.$defs)) {
result.$defs = mapRecordValues(result.$defs);
}
if (isPlainObject(result.dependentSchemas)) {
result.dependentSchemas = mapRecordValues(result.dependentSchemas);
}
if (result.items !== undefined) {
result.items = coerceSchemaNumericFields(result.items);
}
if (result.additionalProperties && typeof result.additionalProperties === "object") {
result.additionalProperties = coerceSchemaNumericFields(result.additionalProperties);
}
if (result.unevaluatedProperties && typeof result.unevaluatedProperties === "object") {
result.unevaluatedProperties = coerceSchemaNumericFields(result.unevaluatedProperties);
}
if (Array.isArray(result.prefixItems)) {
result.prefixItems = result.prefixItems.map((entry) => coerceSchemaNumericFields(entry));
}
if (Array.isArray(result.anyOf)) {
result.anyOf = result.anyOf.map((entry) => coerceSchemaNumericFields(entry));
}
if (Array.isArray(result.oneOf)) {
result.oneOf = result.oneOf.map((entry) => coerceSchemaNumericFields(entry));
}
if (Array.isArray(result.allOf)) {
result.allOf = result.allOf.map((entry) => coerceSchemaNumericFields(entry));
}
if (isPlainObject(result.not)) {
result.not = coerceSchemaNumericFields(result.not);
}
if (isPlainObject(result.if)) {
result.if = coerceSchemaNumericFields(result.if);
}
if (isPlainObject(result.then)) {
result.then = coerceSchemaNumericFields(result.then);
}
if (isPlainObject(result.else)) {
result.else = coerceSchemaNumericFields(result.else);
}
return result;
}
export function sanitizeToolDescription(tool: unknown): unknown {
if (!isPlainObject(tool)) return tool;
const result: JsonRecord = { ...tool };
if (isPlainObject(result.function) && "description" in result.function) {
const description = sanitizeDescriptionValue(result.function.description);
if (description !== undefined) {
result.function = { ...result.function, description };
}
}
if (!isPlainObject(result.function) && "description" in result) {
const description = sanitizeDescriptionValue(result.description);
if (description !== undefined) {
result.description = description;
}
}
if (Array.isArray(result.functionDeclarations)) {
result.functionDeclarations = result.functionDeclarations.map((declaration) => {
if (!isPlainObject(declaration) || !("description" in declaration)) return declaration;
const description = sanitizeDescriptionValue(declaration.description);
return description === undefined ? declaration : { ...declaration, description };
});
}
return result;
}
export function coerceToolSchemas(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (!isPlainObject(tool)) return tool;
const result: JsonRecord = { ...tool };
if (isPlainObject(result.function) && "parameters" in result.function) {
result.function = {
...result.function,
parameters: coerceSchemaNumericFields(result.function.parameters),
};
}
if (result.input_schema !== undefined) {
result.input_schema = coerceSchemaNumericFields(result.input_schema);
}
if ("parameters" in result && !isPlainObject(result.function)) {
result.parameters = coerceSchemaNumericFields(result.parameters);
}
if (Array.isArray(result.functionDeclarations)) {
result.functionDeclarations = result.functionDeclarations.map((declaration) => {
if (!isPlainObject(declaration) || !("parameters" in declaration)) return declaration;
return {
...declaration,
parameters: coerceSchemaNumericFields(declaration.parameters),
};
});
}
return result;
});
}
export function sanitizeToolDescriptions(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => sanitizeToolDescription(tool));
}
export function injectEmptyReasoningContentForToolCalls(
messages: unknown,
provider: unknown
): unknown {
if (!Array.isArray(messages) || String(provider || "").toLowerCase() !== "deepseek") {
return messages;
}
return messages.map((message) => {
if (!isPlainObject(message)) return message;
if (
message.role !== "assistant" ||
!Array.isArray(message.tool_calls) ||
message.tool_calls.length === 0 ||
message.reasoning_content !== undefined
) {
return message;
}
return { ...message, reasoning_content: "" };
});
}
+39 -1
View File
@@ -2,6 +2,11 @@ import { FORMATS } from "./formats.ts";
import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.ts";
import { prepareClaudeRequest } from "./helpers/claudeHelper.ts";
import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts";
import {
coerceToolSchemas,
injectEmptyReasoningContentForToolCalls,
sanitizeToolDescriptions,
} from "./helpers/schemaCoercion.ts";
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
import { bootstrapTranslatorRegistry } from "./bootstrap.ts";
import { normalizeThinkingConfig } from "../services/provider.ts";
@@ -144,8 +149,10 @@ export function translateRequest(
}
// Final step: prepare request for Claude format endpoints
// In Claude passthrough mode (Claude → Claude), preserve cache_control markers
if (targetFormat === FORMATS.CLAUDE) {
result = prepareClaudeRequest(result, provider);
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE;
result = prepareClaudeRequest(result, provider, isClaudePassthrough);
}
// Normalize openai-responses input shape for providers that require list input.
@@ -171,10 +178,41 @@ export function translateRequest(
);
}
if (result.tools !== undefined) {
result.tools = coerceToolSchemas(result.tools);
result.tools = sanitizeToolDescriptions(result.tools);
}
if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) {
result.messages = injectEmptyReasoningContentForToolCalls(result.messages, provider);
}
// Ensure unique tool_call ids on final payload (translators may have introduced duplicates)
ensureToolCallIds(result, { use9CharId });
fixMissingToolResponses(result);
if (result.tools) {
result.tools = coerceToolSchemas(result.tools);
result.tools = sanitizeToolDescriptions(result.tools);
}
// Inject reasoning_content = "" for DeepSeek/Reasoning models assistant messages with tool_calls
// if omitted by the client, to avoid upstream 400 errors (e.g. "Messages with role 'assistant' that contain tool_calls must also include reasoning_content")
const isReasoner =
provider === "deepseek" || (typeof model === "string" && /r1|reason/i.test(model));
if (isReasoner && result.messages && Array.isArray(result.messages)) {
for (const msg of result.messages) {
if (
msg.role === "assistant" &&
Array.isArray(msg.tool_calls) &&
msg.tool_calls.length > 0 &&
msg.reasoning_content === undefined
) {
msg.reasoning_content = "";
}
}
}
return result;
}
@@ -167,8 +167,10 @@ function openaiToGeminiBase(model, body, stream) {
if (tc.type !== "function") continue;
const args = tryParseJSON(tc.function?.arguments || "{}");
// Do NOT include thoughtSignature on functionCall parts — it is only valid
// on thinking/reasoning parts and causes HTTP 400 "invalid argument" from the
// Gemini API when present on a functionCall part (#725).
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
functionCall: {
id: tc.id,
name: tc.function.name,
@@ -90,7 +90,6 @@ export function claudeToOpenAIResponse(chunk, state) {
tool_calls: [
{
index: toolCall.index,
id: toolCall.id,
function: { arguments: delta.partial_json },
},
],
@@ -464,6 +464,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
}
// Function call arguments delta
// NOTE: Do NOT include `id` or `type` here - only first chunk (response.output_item.added)
// should have them. Including `id` on every chunk causes openai-to-claude.ts to emit
// a new content_block_start for each delta, breaking Claude Code ACP sessions.
if (eventType === "response.function_call_arguments.delta") {
const argsDelta = data.delta || "";
if (!argsDelta) return null;
@@ -480,8 +483,6 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
tool_calls: [
{
index: state.toolCallIndex,
id: state.currentToolCallId,
type: "function",
function: { arguments: argsDelta },
},
],
+6 -1
View File
@@ -1,5 +1,6 @@
import { getCorsOrigin } from "./cors.ts";
import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.ts";
import { normalizePayloadForLog } from "@/lib/logPayloads";
/**
* Build OpenAI-compatible error response body
@@ -91,14 +92,16 @@ export function parseAntigravityRetryTime(message) {
* Parse upstream provider error response
* @param {Response} response - Fetch response from provider
* @param {string} provider - Provider name (for Antigravity-specific parsing)
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null}>}
* @returns {Promise<{statusCode: number, message: string, retryAfterMs: number|null, responseBody: unknown}>}
*/
export async function parseUpstreamError(response, provider = null) {
let message = "";
let retryAfterMs = null;
let responseBody = null;
try {
const text = await response.text();
responseBody = normalizePayloadForLog(text);
// Try parse as JSON
try {
@@ -109,6 +112,7 @@ export async function parseUpstreamError(response, provider = null) {
}
} catch {
message = `Upstream error: ${response.status}`;
responseBody = { _rawText: message };
}
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
@@ -122,6 +126,7 @@ export async function parseUpstreamError(response, provider = null) {
statusCode: response.status,
message: messageStr,
retryAfterMs,
responseBody,
};
}
+52 -2
View File
@@ -11,6 +11,7 @@ import {
COLORS,
} from "./usageTracking.ts";
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.ts";
import { createStructuredSSECollector } from "./streamPayloadCollector.ts";
import { STREAM_IDLE_TIMEOUT_MS, HTTP_STATUS } from "../config/constants.ts";
import {
sanitizeStreamingChunk,
@@ -32,6 +33,8 @@ type StreamCompletePayload = {
usage: unknown;
/** Minimal response body for call log (streaming: usage + note; non-streaming not used) */
responseBody?: unknown;
providerPayload?: unknown;
clientPayload?: unknown;
};
type StreamOptions = {
@@ -158,6 +161,12 @@ export function createSSEStream(options: StreamOptions = {}) {
// Guard against duplicate [DONE] events — ensures exactly one per stream
let doneSent = false;
const providerPayloadCollector = createStructuredSSECollector({
stage: "provider_response",
});
const clientPayloadCollector = createStructuredSSECollector({
stage: "client_response",
});
// Per-stream instances to avoid shared state with concurrent streams
const decoder = new TextDecoder();
@@ -212,6 +221,17 @@ export function createSSEStream(options: StreamOptions = {}) {
if (mode === STREAM_MODE.PASSTHROUGH) {
let output;
let injectedUsage = false;
let clientPayload: unknown = null;
if (trimmed.startsWith("data:")) {
const providerPayload = parseSSELine(trimmed);
if (providerPayload) {
providerPayloadCollector.push(providerPayload);
if ((providerPayload as { done?: unknown }).done === true) {
clientPayloadCollector.push(providerPayload);
}
}
}
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
@@ -380,6 +400,8 @@ export function createSSEStream(options: StreamOptions = {}) {
injectedUsage = true;
}
}
clientPayload = parsed;
} catch {}
}
@@ -391,6 +413,10 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
if (clientPayload) {
clientPayloadCollector.push(clientPayload);
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
continue;
@@ -401,10 +427,12 @@ export function createSSEStream(options: StreamOptions = {}) {
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
providerPayloadCollector.push(parsed);
if (parsed && parsed.done) {
if (!doneSent) {
doneSent = true;
clientPayloadCollector.push({ done: true });
const output = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
@@ -524,6 +552,7 @@ export function createSSEStream(options: StreamOptions = {}) {
}
const output = formatSSE(item, sourceFormat);
clientPayloadCollector.push(item);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
@@ -551,6 +580,11 @@ export function createSSEStream(options: StreamOptions = {}) {
if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) {
output = "data: " + buffer.slice(5);
}
const bufferedPayload = parseSSELine(buffer.trim());
if (bufferedPayload) {
providerPayloadCollector.push(bufferedPayload);
clientPayloadCollector.push(bufferedPayload);
}
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
@@ -601,7 +635,13 @@ export function createSSEStream(options: StreamOptions = {}) {
},
_streamed: true,
};
onComplete({ status: 200, usage, responseBody });
onComplete({
status: 200,
usage,
responseBody,
providerPayload: providerPayloadCollector.build(),
clientPayload: clientPayloadCollector.build(responseBody),
});
} catch {}
}
return;
@@ -611,6 +651,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
providerPayloadCollector.push(parsed);
// Extract usage from remaining buffer — if the usage-bearing event
// (e.g. response.completed) is the last SSE line, it ends up here
// in the flush handler where extractUsage was not called.
@@ -647,6 +688,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (translated?.length > 0) {
for (const item of translated) {
const output = formatSSE(item, sourceFormat);
clientPayloadCollector.push(item);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
@@ -666,6 +708,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (flushed?.length > 0) {
for (const item of flushed) {
const output = formatSSE(item, sourceFormat);
clientPayloadCollector.push(item);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
@@ -684,6 +727,7 @@ export function createSSEStream(options: StreamOptions = {}) {
// Send [DONE] (only if not already sent during transform)
if (!doneSent) {
doneSent = true;
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
@@ -747,7 +791,13 @@ export function createSSEStream(options: StreamOptions = {}) {
},
_streamed: true,
};
onComplete({ status: 200, usage: state?.usage, responseBody });
onComplete({
status: 200,
usage: state?.usage,
responseBody,
providerPayload: providerPayloadCollector.build(),
clientPayload: clientPayloadCollector.build(responseBody),
});
} catch {}
}
} catch (error) {
+2 -2
View File
@@ -65,8 +65,8 @@ export function hasValuableContent(chunk, format) {
return true;
}
// Gemini format: filter chunks with no actual content parts
if (format === FORMATS.GEMINI && chunk.candidates?.[0]) {
// Gemini / Antigravity format: filter chunks with no actual content parts
if ((format === FORMATS.GEMINI || format === FORMATS.ANTIGRAVITY) && chunk.candidates?.[0]) {
const candidate = chunk.candidates[0];
// Keep chunks with finish reason or safety ratings (they signal completion)
if (candidate.finishReason) return true;
+72
View File
@@ -0,0 +1,72 @@
import { cloneLogPayload } from "@/lib/logPayloads";
type StructuredSSEEvent = {
index: number;
event?: string;
data: unknown;
};
type CollectorOptions = {
maxEvents?: number;
maxBytes?: number;
stage?: string;
};
function getEventName(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
if (typeof (payload as { event?: unknown }).event === "string") {
return (payload as { event: string }).event;
}
if (typeof (payload as { type?: unknown }).type === "string") {
return (payload as { type: string }).type;
}
if ((payload as { done?: unknown }).done === true) {
return "[DONE]";
}
return undefined;
}
export function createStructuredSSECollector(options: CollectorOptions = {}) {
const { maxEvents = 200, maxBytes = 49152, stage } = options;
const events: StructuredSSEEvent[] = [];
let usedBytes = 0;
let droppedEvents = 0;
return {
push(payload: unknown, explicitEvent?: string) {
if (payload === null || payload === undefined) return;
const event: StructuredSSEEvent = {
index: events.length + droppedEvents,
data: cloneLogPayload(payload),
};
const eventName = explicitEvent || getEventName(payload);
if (eventName) {
event.event = eventName;
}
const serializedSize = JSON.stringify(event).length;
if (events.length >= maxEvents || usedBytes + serializedSize > maxBytes) {
droppedEvents += 1;
return;
}
usedBytes += serializedSize;
events.push(event);
},
build(summary?: unknown) {
return {
_streamed: true,
_format: "sse-json",
...(stage ? { _stage: stage } : {}),
_eventCount: events.length + droppedEvents,
...(droppedEvents > 0 ? { _truncated: true, _droppedEvents: droppedEvents } : {}),
events,
...(summary === undefined ? {} : { summary: cloneLogPayload(summary) }),
};
},
};
}
+12 -8
View File
@@ -3,6 +3,12 @@
*/
import { saveRequestUsage, appendRequestLog } from "@/lib/usageDb";
import {
getLoggedInputTokens,
getLoggedOutputTokens,
getPromptCacheCreationTokens,
getPromptCacheReadTokens,
} from "@/lib/usage/tokenAccounting";
import { FORMATS } from "../translator/formats.ts";
// ANSI color codes
@@ -415,8 +421,8 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
// Support both formats:
// - OpenAI: prompt_tokens, completion_tokens
// - Claude: input_tokens, output_tokens
const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0;
const outTokens = usage?.completion_tokens || usage?.output_tokens || 0;
const inTokens = getLoggedInputTokens(usage);
const outTokens = getLoggedOutputTokens(usage);
const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown";
let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`;
@@ -427,10 +433,10 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
}
// Add cache info if present (unified from different formats)
const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens;
const cacheRead = getPromptCacheReadTokens(usage);
if (cacheRead) msg += ` | cache_read=${cacheRead}`;
const cacheCreation = usage.cache_creation_input_tokens;
const cacheCreation = getPromptCacheCreationTokens(usage);
if (cacheCreation) msg += ` | cache_create=${cacheCreation}`;
const reasoning = usage.reasoning_tokens;
@@ -438,11 +444,9 @@ export function logUsage(provider, usage, model = null, connectionId = null, api
console.log(msg);
// Save to usage DB
// input = total input tokens (non-cached + cache_read + cache_creation)
// This ensures analytics show correct totals for heavily-cached requests
// Save to usage DB with cache-read tracked separately from the main input counter.
const tokens = {
input: inTokens + (cacheRead || 0) + (cacheCreation || 0),
input: inTokens,
output: outTokens,
cacheRead: cacheRead || 0,
cacheCreation: cacheCreation || 0,
+328 -11
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.1.6",
"version": "3.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.1.6",
"version": "3.2.2",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -60,6 +60,7 @@
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"c8": "^11.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
@@ -511,6 +512,16 @@
}
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@braintree/sanitize-url": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
@@ -2160,6 +2171,16 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -6007,6 +6028,13 @@
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -6318,9 +6346,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7546,9 +7574,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7659,6 +7687,40 @@
"node": ">=6.0.0"
}
},
"node_modules/c8": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz",
"integrity": "sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^17.7.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "20 || >=22"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -10522,6 +10584,23 @@
"node": ">=0.10.0"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
@@ -10803,6 +10882,24 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -10816,6 +10913,45 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -11282,6 +11418,13 @@
"node": ">=16.9.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -12210,6 +12353,45 @@
"node": ">=0.10.0"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -13059,6 +13241,35 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/markdown-extensions": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
@@ -14461,6 +14672,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mixin-deep": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz",
@@ -15278,10 +15499,37 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -18144,6 +18392,60 @@
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
@@ -18902,6 +19204,21 @@
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/v8n": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz",
+8 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.1.7",
"version": "3.2.2",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -72,7 +72,10 @@
"test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs",
"test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts",
"test:ecosystem": "node scripts/run-ecosystem-tests.mjs",
"test:coverage": "npx c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
"test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs",
"test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
"coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
"coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary",
"test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e",
"check": "npm run lint && npm run test",
"prepublishOnly": "npm run build:cli",
@@ -124,6 +127,7 @@
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"c8": "^11.0.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
@@ -157,6 +161,7 @@
]
},
"overrides": {
"dompurify": "^3.3.2"
"dompurify": "^3.3.2",
"path-to-regexp": "^8.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4A90E2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

+36 -6
View File
@@ -109,6 +109,15 @@ const LOCALE_SPECS = [
readmeName: "ไทย",
docsName: "ไทย",
},
{
code: "tr",
googleTl: "tr",
label: "TR",
flag: "🇹🇷",
languageName: "Türkçe",
readmeName: "Türkçe",
docsName: "Türkçe",
},
{
code: "uk-UA",
googleTl: "uk",
@@ -608,6 +617,9 @@ function collectStringLeaves(node, pathSoFar = [], output = []) {
function setByPath(target, pathTokens, value) {
let current = target;
for (let i = 0; i < pathTokens.length - 1; i += 1) {
if (current[pathTokens[i]] === undefined) {
current[pathTokens[i]] = typeof pathTokens[i + 1] === "number" ? [] : {};
}
current = current[pathTokens[i]];
}
current[pathTokens[pathTokens.length - 1]] = value;
@@ -728,25 +740,43 @@ async function generateMessageTranslations() {
const sourceJson = JSON.parse(sourceRaw);
const leaves = collectStringLeaves(sourceJson);
const sourceValues = leaves.map((entry) => entry.value);
for (const spec of LOCALE_SPECS) {
if (spec.code === "en" || spec.code === "pt-BR") {
if (spec.code === "en") {
continue;
}
const targetPath = path.join(MESSAGES_DIR, `${spec.code}.json`);
let targetJson = {};
if (await fileExists(targetPath)) {
console.log(`[messages] Skipping ${spec.code} (already exists).`);
const targetRaw = await fs.readFile(targetPath, "utf8");
try {
targetJson = JSON.parse(targetRaw);
} catch (e) {
console.warn(`[messages] Failed to parse ${spec.code}.json`);
}
}
const missingLeaves = leaves.filter((leaf) => {
let current = targetJson;
for (const token of leaf.path) {
if (current === undefined || current === null) return true;
current = current[token];
}
return current === undefined || current === null || current === "";
});
if (missingLeaves.length === 0) {
console.log(`[messages] ${spec.code} is up-to-date.`);
continue;
}
console.log(`[messages] Translating ${spec.code}...`);
console.log(`[messages] Translating ${missingLeaves.length} missing keys for ${spec.code}...`);
const sourceValues = missingLeaves.map((entry) => entry.value);
const translatedValues = await translateStrings(sourceValues, spec.googleTl);
const targetJson = structuredClone(sourceJson);
translatedValues.forEach((value, index) => {
setByPath(targetJson, leaves[index].path, value);
setByPath(targetJson, missingLeaves[index].path, value);
});
await fs.writeFile(targetPath, `${JSON.stringify(targetJson, null, 2)}\n`, "utf8");
+1 -1
View File
@@ -45,7 +45,7 @@ async function main() {
const vitestProcess = spawn(
process.execPath,
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/protocol-clients.test.ts"],
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/protocol-clients.test.ts", "--dir", "tests"],
{
stdio: "inherit",
env: testEnv,
+2 -1
View File
@@ -15,7 +15,7 @@
*/
import { execSync } from "child_process";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import os from "os";
@@ -162,6 +162,7 @@ const outFile = outArg
const outPath = join(ROOT, outFile);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, report);
console.log(report);
console.log(`\n✅ Report saved to: ${outPath}`);
+340
View File
@@ -0,0 +1,340 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
// ─── Types ───────────────────────────────────────────────────────────────────
interface SemanticCacheStats {
memoryEntries: number;
dbEntries: number;
hits: number;
misses: number;
hitRate: string;
tokensSaved: number;
}
interface IdempotencyStats {
activeKeys: number;
windowMs: number;
}
interface CacheStats {
semanticCache: SemanticCacheStats;
idempotency: IdempotencyStats;
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function StatCard({
icon,
label,
value,
sub,
valueClass = "text-text",
}: {
icon: string;
label: string;
value: string | number;
sub?: string;
valueClass?: string;
}) {
return (
<div className="flex flex-col gap-1 p-4 rounded-xl bg-surface-raised border border-border/40">
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<span className="material-symbols-outlined text-base leading-none" aria-hidden="true">
{icon}
</span>
{label}
</div>
<div className={`text-2xl font-semibold tabular-nums ${valueClass}`}>{value}</div>
{sub && <div className="text-xs text-text-muted">{sub}</div>}
</div>
);
}
function HitRateBar({ hitRate, label }: { hitRate: number; label: string }) {
const colorClass = hitRate >= 70 ? "bg-green-500" : hitRate >= 40 ? "bg-amber-400" : "bg-red-500";
const textClass =
hitRate >= 70 ? "text-green-500" : hitRate >= 40 ? "text-amber-400" : "text-red-500";
return (
<div
className="w-full"
role="progressbar"
aria-label={label}
aria-valuenow={hitRate}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="flex justify-between text-xs mb-1.5">
<span className="text-text-muted">{label}</span>
<span className={`font-semibold tabular-nums ${textClass}`}>{hitRate.toFixed(1)}%</span>
</div>
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${colorClass}`}
style={{ width: `${Math.min(hitRate, 100)}%` }}
/>
</div>
</div>
);
}
function InfoRow({ icon, children }: { icon: string; children: React.ReactNode }) {
return (
<div className="flex gap-2 text-sm text-text-muted">
<span
className="material-symbols-outlined text-base leading-5 text-blue-400 shrink-0"
aria-hidden="true"
>
{icon}
</span>
<span>{children}</span>
</div>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
const REFRESH_INTERVAL_MS = 10_000;
const REFRESH_INTERVAL_SECONDS = REFRESH_INTERVAL_MS / 1000;
export default function CachePage() {
const t = useTranslations("cache");
const [stats, setStats] = useState<CacheStats | null>(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const notify = useNotificationStore();
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/cache");
if (res.ok) {
const data: CacheStats = await res.json();
setStats(data);
}
} catch (error) {
// Network error — keep stale stats rather than clearing the UI
console.error("[CachePage] Failed to fetch cache stats:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchStats();
const id = setInterval(() => void fetchStats(), REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [fetchStats]);
const handleClearAll = async () => {
setClearing(true);
try {
const res = await fetch("/api/cache", { method: "DELETE" });
if (res.ok) {
const data = await res.json();
notify.add({
type: "success",
message: t("clearSuccess", { count: data.expiredRemoved ?? 0 }),
});
await fetchStats();
} else {
notify.add({ type: "error", message: t("clearError") });
}
} catch (error) {
console.error("[CachePage] Failed to clear cache:", error);
notify.add({ type: "error", message: t("clearError") });
} finally {
setClearing(false);
}
};
const sc = stats?.semanticCache;
const idp = stats?.idempotency;
const hitRate = sc ? parseFloat(sc.hitRate) : 0;
const totalRequests = sc ? sc.hits + sc.misses : 0;
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold">{t("title")}</h1>
<p className="text-sm text-text-muted mt-0.5">{t("description")}</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
variant="secondary"
icon="refresh"
size="sm"
onClick={() => void fetchStats()}
disabled={loading}
aria-label={t("refresh")}
>
{t("refresh")}
</Button>
<Button
variant="danger"
icon="delete_sweep"
size="sm"
onClick={() => void handleClearAll()}
disabled={clearing || loading}
loading={clearing}
aria-label={t("clearAll")}
>
{t("clearAll")}
</Button>
</div>
</div>
{/* Loading skeleton */}
{loading && (
<div
className="grid grid-cols-2 md:grid-cols-4 gap-4"
aria-busy="true"
aria-label="Loading cache statistics"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-xl bg-surface-raised animate-pulse" />
))}
</div>
)}
{/* Error / empty state */}
{!loading && !stats && (
<EmptyState
icon="cached"
title={t("unavailable")}
description={t("unavailableDesc")}
actionLabel={t("refresh")}
onAction={() => void fetchStats()}
/>
)}
{/* Main content */}
{!loading && stats && (
<>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
icon="memory"
label={t("memoryEntries")}
value={sc?.memoryEntries ?? 0}
sub={t("memoryEntriesSub")}
/>
<StatCard
icon="storage"
label={t("dbEntries")}
value={sc?.dbEntries ?? 0}
sub={t("dbEntriesSub")}
/>
<StatCard
icon="trending_up"
label={t("cacheHits")}
value={sc?.hits ?? 0}
sub={t("cacheHitsSub", { total: totalRequests })}
valueClass="text-green-500"
/>
<StatCard
icon="token"
label={t("tokensSaved")}
value={(sc?.tokensSaved ?? 0).toLocaleString()}
sub={t("tokensSavedSub")}
valueClass="text-blue-400"
/>
</div>
{/* Hit rate + breakdown */}
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="font-medium text-sm">{t("performance")}</h2>
<span className="text-xs text-text-muted">
{t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })}
</span>
</div>
<HitRateBar hitRate={hitRate} label={t("hitRate")} />
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
<div>
<div className="text-lg font-semibold tabular-nums text-green-500">
{sc?.hits ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("hits")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums text-red-400">
{sc?.misses ?? 0}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("misses")}</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums">{totalRequests}</div>
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
</div>
</div>
</div>
</Card>
{/* Cache behavior */}
<Card>
<div className="p-5 flex flex-col gap-3">
<h2 className="font-medium text-sm">{t("behavior")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<InfoRow icon="info">{t("behaviorDeterministic")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorBypass", {
header: () => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
X-OmniRoute-No-Cache: true
</code>
),
})}
</InfoRow>
<InfoRow icon="info">{t("behaviorTwoTier")}</InfoRow>
<InfoRow icon="info">
{t.rich("behaviorTtl", {
envVar: () => (
<code className="bg-surface px-1 py-0.5 rounded text-xs font-mono">
SEMANTIC_CACHE_TTL_MS
</code>
),
})}
</InfoRow>
</div>
</div>
</Card>
{/* Idempotency */}
<Card>
<div className="p-5 flex flex-col gap-3">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
fingerprint
</span>
<h2 className="font-medium text-sm">{t("idempotency")}</h2>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">{idp?.activeKeys ?? 0}</div>
<div className="text-xs text-text-muted mt-0.5">{t("activeDedupKeys")}</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums">
{idp ? `${(idp.windowMs / 1000).toFixed(0)}s` : "—"}
</div>
<div className="text-xs text-text-muted mt-0.5">{t("dedupWindow")}</div>
</div>
</div>
</div>
</Card>
</>
)}
</div>
);
}
+18 -7
View File
@@ -1122,18 +1122,27 @@ function TestResultsView({ results }) {
{results.results?.map((r, i) => (
<div
key={i}
title={r.error || undefined}
className="flex items-center gap-2 text-xs px-2 py-1.5 rounded bg-black/[0.02] dark:bg-white/[0.02]"
>
<span
className={`material-symbols-outlined text-[14px] ${
r.status === "ok"
? "text-emerald-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
: r.status === "reachable"
? "text-amber-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
}`}
>
{r.status === "ok" ? "check_circle" : r.status === "skipped" ? "skip_next" : "error"}
{r.status === "ok"
? "check_circle"
: r.status === "reachable"
? "network_check"
: r.status === "skipped"
? "skip_next"
: "error"}
</span>
<code className="font-mono flex-1">{r.model}</code>
{r.latencyMs !== undefined && <span className="text-text-muted">{r.latencyMs}ms</span>}
@@ -1141,9 +1150,11 @@ function TestResultsView({ results }) {
className={`text-[10px] uppercase font-medium ${
r.status === "ok"
? "text-emerald-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
: r.status === "reachable"
? "text-amber-500"
: r.status === "skipped"
? "text-text-muted"
: "text-red-500"
}`}
>
{r.status}
@@ -403,6 +403,10 @@ interface ConnectionRowProps {
proxyHost?: string;
onRefreshToken?: () => void;
isRefreshing?: boolean;
onApplyCodexAuthLocal?: () => void;
isApplyingCodexAuthLocal?: boolean;
onExportCodexAuthFile?: () => void;
isExportingCodexAuthFile?: boolean;
}
interface AddApiKeyModalProps {
@@ -821,6 +825,8 @@ export default function ProviderDetailPage() {
modelCompatOverrides: Array<CompatModelRow & { id: string }>;
}>({ customModels: [], modelCompatOverrides: [] });
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
const providerInfo = providerNode
? {
@@ -1248,6 +1254,39 @@ export default function ProviderDetailPage() {
// T12: Manual token refresh
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const parseApiErrorMessage = async (res: Response, fallback: string) => {
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const data = await res.json().catch(() => ({}));
if (typeof data?.error === "string" && data.error.trim()) {
return data.error;
}
if (data?.error?.message) {
return data.error.message;
}
}
const text = await res.text().catch(() => "");
return text.trim() || fallback;
};
const getAttachmentFilename = (res: Response, fallback: string) => {
const disposition = res.headers.get("content-disposition") || "";
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1]);
}
const plainMatch = disposition.match(/filename="([^"]+)"/i);
if (plainMatch?.[1]) {
return plainMatch[1];
}
return fallback;
};
const handleRefreshToken = async (connectionId: string) => {
if (refreshingId) return;
setRefreshingId(connectionId);
@@ -1268,6 +1307,82 @@ export default function ProviderDetailPage() {
}
};
const handleApplyCodexAuthLocal = async (connectionId: string) => {
if (applyingCodexAuthId) return;
setApplyingCodexAuthId(connectionId);
const defaultSuccess =
typeof t.has === "function" && t.has("codexAuthAppliedLocal")
? t("codexAuthAppliedLocal")
: "Codex auth.json applied locally";
const defaultError =
typeof t.has === "function" && t.has("codexAuthApplyFailed")
? t("codexAuthApplyFailed")
: "Failed to apply Codex auth.json locally";
try {
const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
notify.success(defaultSuccess);
} catch (error) {
console.error("Error applying Codex auth locally:", error);
notify.error(defaultError);
} finally {
setApplyingCodexAuthId(null);
}
};
const handleExportCodexAuthFile = async (connectionId: string) => {
if (exportingCodexAuthId) return;
setExportingCodexAuthId(connectionId);
const defaultSuccess =
typeof t.has === "function" && t.has("codexAuthExported")
? t("codexAuthExported")
: "Codex auth.json exported";
const defaultError =
typeof t.has === "function" && t.has("codexAuthExportFailed")
? t("codexAuthExportFailed")
: "Failed to export Codex auth.json";
try {
const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
const blob = await res.blob();
const filename = getAttachmentFilename(res, "codex-auth.json");
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
notify.success(defaultSuccess);
} catch (error) {
console.error("Error exporting Codex auth file:", error);
notify.error(defaultError);
} finally {
setExportingCodexAuthId(null);
}
};
const handleSwapPriority = async (conn1, conn2) => {
if (!conn1 || !conn2) return;
try {
@@ -2103,6 +2218,18 @@ export default function ProviderDetailPage() {
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
isRefreshing={refreshingId === conn.id}
onApplyCodexAuthLocal={
providerId === "codex"
? () => handleApplyCodexAuthLocal(conn.id)
: undefined
}
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
onExportCodexAuthFile={
providerId === "codex"
? () => handleExportCodexAuthFile(conn.id)
: undefined
}
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
onProxy={() =>
setProxyTarget({
level: "key",
@@ -2194,6 +2321,18 @@ export default function ProviderDetailPage() {
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
isRefreshing={refreshingId === conn.id}
onApplyCodexAuthLocal={
providerId === "codex"
? () => handleApplyCodexAuthLocal(conn.id)
: undefined
}
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
onExportCodexAuthFile={
providerId === "codex"
? () => handleExportCodexAuthFile(conn.id)
: undefined
}
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
onProxy={() =>
setProxyTarget({
level: "key",
@@ -3776,11 +3915,23 @@ function ConnectionRow({
proxyHost,
onRefreshToken,
isRefreshing,
onApplyCodexAuthLocal,
isApplyingCodexAuthLocal,
onExportCodexAuthFile,
isExportingCodexAuthFile,
}: ConnectionRowProps) {
const t = useTranslations("providers");
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || t("oauthAccount")
: connection.name;
const applyCodexAuthLabel =
typeof t.has === "function" && t.has("applyCodexAuthLocal")
? t("applyCodexAuthLocal")
: "Apply auth";
const exportCodexAuthLabel =
typeof t.has === "function" && t.has("exportCodexAuthFile")
? t("exportCodexAuthFile")
: "Export auth";
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
@@ -4014,6 +4165,34 @@ function ConnectionRow({
Token
</Button>
)}
{isCodex && onApplyCodexAuthLocal && (
<Button
size="sm"
variant="ghost"
icon="download_done"
loading={isApplyingCodexAuthLocal}
disabled={isApplyingCodexAuthLocal}
onClick={onApplyCodexAuthLocal}
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
title={applyCodexAuthLabel}
>
{applyCodexAuthLabel}
</Button>
)}
{isCodex && onExportCodexAuthFile && (
<Button
size="sm"
variant="ghost"
icon="download"
loading={isExportingCodexAuthFile}
disabled={isExportingCodexAuthFile}
onClick={onExportCodexAuthFile}
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
title={exportCodexAuthLabel}
>
{exportCodexAuthLabel}
</Button>
)}
<Toggle
size="sm"
checked={connection.isActive ?? true}
@@ -4090,6 +4269,10 @@ ConnectionRow.propTypes = {
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onReauth: PropTypes.func,
onApplyCodexAuthLocal: PropTypes.func,
isApplyingCodexAuthLocal: PropTypes.bool,
onExportCodexAuthFile: PropTypes.func,
isExportingCodexAuthFile: PropTypes.bool,
};
function AddApiKeyModal({
@@ -4106,6 +4289,7 @@ function AddApiKeyModal({
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const isVertex = provider === "vertex";
const defaultRegion = "us-central1";
const isGlm = provider === "glm";
const [formData, setFormData] = useState({
name: "",
@@ -4113,6 +4297,7 @@ function AddApiKeyModal({
priority: 1,
baseUrl: isBailian ? defaultBailianUrl : "",
region: isVertex ? defaultRegion : "",
apiRegion: "international",
validationModelId: "",
});
const [validating, setValidating] = useState(false);
@@ -4202,6 +4387,10 @@ function AddApiKeyModal({
payload.providerSpecificData = {
region: formData.region,
};
} else if (isGlm) {
payload.providerSpecificData = {
apiRegion: formData.apiRegion,
};
}
const error = await onSave(payload);
@@ -4301,6 +4490,22 @@ function AddApiKeyModal({
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">API Region</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">International (api.z.ai)</option>
<option value="china">China Mainland (open.bigmodel.cn)</option>
</select>
<p className="text-xs text-text-muted mt-1">
Select the endpoint region for API access and quota tracking.
</p>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
@@ -4350,6 +4555,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
healthCheckInterval: 60,
baseUrl: "",
region: "",
apiRegion: "international",
validationModelId: "",
tag: "",
});
@@ -4365,6 +4571,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const isBailian = connection?.provider === "bailian-coding-plan";
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
const isVertex = connection?.provider === "vertex";
const isGlm = connection?.provider === "glm";
const defaultRegion = "us-central1";
useEffect(() => {
@@ -4380,6 +4587,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
healthCheckInterval: connection.healthCheckInterval ?? 60,
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
region: existingRegion || (isVertex ? defaultRegion : ""),
apiRegion: (connection.providerSpecificData?.apiRegion as string) || "international",
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
tag: (connection.providerSpecificData?.tag as string) || "",
});
@@ -4515,6 +4723,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
} else if (isVertex) {
updates.providerSpecificData.region = formData.region;
} else if (isGlm) {
updates.providerSpecificData.apiRegion = formData.apiRegion;
}
} else {
// Also persist tag for OAuth accounts
@@ -4649,6 +4859,23 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
/>
)}
{isGlm && (
<div>
<label className="text-sm font-medium text-text-main mb-1 block">API Region</label>
<select
value={formData.apiRegion}
onChange={(e) => setFormData({ ...formData, apiRegion: e.target.value })}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="international">International (api.z.ai)</option>
<option value="china">China Mainland (open.bigmodel.cn)</option>
</select>
<p className="text-xs text-text-muted mt-1">
Select the endpoint region for API access and quota tracking.
</p>
</div>
)}
{/* T07: Extra API Keys for round-robin rotation */}
{!isOAuth && (
<div className="flex flex-col gap-2">
@@ -18,6 +18,11 @@ export default function SystemStorageTab() {
const [importStatus, setImportStatus] = useState({ type: "", message: "" });
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
const [maxCallLogs, setMaxCallLogs] = useState(10000);
const [maxCallLogsDraft, setMaxCallLogsDraft] = useState("10000");
const [settingsLoading, setSettingsLoading] = useState(true);
const [maxCallLogsSaving, setMaxCallLogsSaving] = useState(false);
const [maxCallLogsStatus, setMaxCallLogsStatus] = useState({ type: "", message: "" });
const fileInputRef = useRef<HTMLInputElement>(null);
const locale = useLocale();
const t = useTranslations("settings");
@@ -54,6 +59,27 @@ export default function SystemStorageTab() {
}
};
const loadSettings = async () => {
setSettingsLoading(true);
try {
const res = await fetch("/api/settings");
if (!res.ok) return;
const data = await res.json();
const value =
typeof data.maxCallLogs === "number" &&
Number.isInteger(data.maxCallLogs) &&
data.maxCallLogs > 0
? data.maxCallLogs
: 10000;
setMaxCallLogs(value);
setMaxCallLogsDraft(String(value));
} catch (err) {
console.error("Failed to fetch settings:", err);
} finally {
setSettingsLoading(false);
}
};
const handleManualBackup = async () => {
setManualBackupLoading(true);
setManualBackupStatus({ type: "", message: "" });
@@ -119,8 +145,47 @@ export default function SystemStorageTab() {
useEffect(() => {
loadStorageHealth();
loadSettings();
}, []);
const handleSaveMaxCallLogs = async () => {
const parsed = Number.parseInt(maxCallLogsDraft, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
setMaxCallLogsStatus({
type: "error",
message: "Enter a positive integer for the call log limit.",
});
return;
}
setMaxCallLogsSaving(true);
setMaxCallLogsStatus({ type: "", message: "" });
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ maxCallLogs: parsed }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to save call log limit");
}
setMaxCallLogs(parsed);
setMaxCallLogsDraft(String(parsed));
setMaxCallLogsStatus({
type: "success",
message: "Call log retention limit saved.",
});
} catch (err) {
setMaxCallLogsStatus({
type: "error",
message: (err as Error).message || "Failed to save call log limit",
});
} finally {
setMaxCallLogsSaving(false);
}
};
const handleExport = async () => {
setExportLoading(true);
try {
@@ -276,6 +341,56 @@ export default function SystemStorageTab() {
</div>
</div>
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-text-main">Call log retention limit</p>
<p className="text-xs text-text-muted">
Keep only the most recent call log entries in SQLite. Older entries are pruned
automatically after each new request log is saved.
</p>
</div>
<Badge variant="default" size="sm">
{maxCallLogs.toLocaleString()}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 mt-3">
<input
type="number"
min="1"
step="1"
value={maxCallLogsDraft}
onChange={(e) => setMaxCallLogsDraft(e.target.value)}
disabled={settingsLoading || maxCallLogsSaving}
className="w-40 rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-primary/40"
aria-label="Call log retention limit"
/>
<Button
variant="outline"
size="sm"
onClick={handleSaveMaxCallLogs}
loading={maxCallLogsSaving}
disabled={settingsLoading}
>
Save limit
</Button>
</div>
{maxCallLogsStatus.message && (
<div
className={`mt-3 rounded-lg border px-3 py-2 text-sm ${
maxCallLogsStatus.type === "success"
? "border-green-500/20 bg-green-500/10 text-green-500"
: "border-red-500/20 bg-red-500/10 text-red-500"
}`}
role="alert"
>
{maxCallLogsStatus.message}
</div>
)}
</div>
{/* Export / Import */}
<div className="flex flex-wrap items-center gap-2 mb-4">
<Button variant="outline" size="sm" onClick={handleExport} loading={exportLoading}>
@@ -4,7 +4,7 @@ import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Image from "next/image";
import { parseQuotaData, calculatePercentage, normalizePlanTier } from "./utils";
import { parseQuotaData, calculatePercentage, normalizePlanTier, resolvePlanValue } from "./utils";
import Card from "@/shared/components/Card";
import Badge from "@/shared/components/Badge";
import { CardSkeleton } from "@/shared/components/Loading";
@@ -16,6 +16,8 @@ const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
const REFRESH_INTERVAL_MS = 120000;
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
const QUOTA_BAR_GREEN_THRESHOLD = 50;
const QUOTA_BAR_YELLOW_THRESHOLD = 20;
// Provider display config
const PROVIDER_CONFIG = {
@@ -24,7 +26,7 @@ const PROVIDER_CONFIG = {
kiro: { label: "Kiro AI", color: "#FF6B35" },
codex: { label: "OpenAI Codex", color: "#10A37F" },
claude: { label: "Claude Code", color: "#D97757" },
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
glm: { label: "GLM Coding", color: "#4A90D9" },
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
};
@@ -64,9 +66,13 @@ function getShortModelName(name) {
}
// Get bar color based on remaining percentage
function getBarColor(remaining) {
if (remaining > 70) return { bar: "#22c55e", text: "#22c55e", bg: "rgba(34,197,94,0.12)" };
if (remaining >= 30) return { bar: "#eab308", text: "#eab308", bg: "rgba(234,179,8,0.12)" };
function getBarColor(remainingPercentage) {
if (remainingPercentage > QUOTA_BAR_GREEN_THRESHOLD) {
return { bar: "#22c55e", text: "#22c55e", bg: "rgba(34,197,94,0.12)" };
}
if (remainingPercentage > QUOTA_BAR_YELLOW_THRESHOLD) {
return { bar: "#eab308", text: "#eab308", bg: "rgba(234,179,8,0.12)" };
}
return { bar: "#ef4444", text: "#ef4444", bg: "rgba(239,68,68,0.12)" };
}
@@ -297,14 +303,22 @@ export default function ProviderLimits() {
);
}, [filteredConnections]);
const tierByConnection = useMemo(() => {
const resolvedPlanByConnection = useMemo(() => {
const out = {};
for (const conn of sortedConnections) {
out[conn.id] = normalizePlanTier(quotaData[conn.id]?.plan);
out[conn.id] = resolvePlanValue(quotaData[conn.id]?.plan, conn.providerSpecificData);
}
return out;
}, [sortedConnections, quotaData]);
const tierByConnection = useMemo(() => {
const out = {};
for (const conn of sortedConnections) {
out[conn.id] = normalizePlanTier(resolvedPlanByConnection[conn.id]);
}
return out;
}, [sortedConnections, resolvedPlanByConnection]);
const tierCounts = useMemo(() => {
const counts = {
all: sortedConnections.length,
@@ -313,6 +327,7 @@ export default function ProviderLimits() {
business: 0,
ultra: 0,
pro: 0,
plus: 0,
free: 0,
unknown: 0,
};
@@ -533,6 +548,7 @@ export default function ProviderLimits() {
color: "#666",
};
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
const resolvedPlan = resolvedPlanByConnection[conn.id];
return (
<div
@@ -558,21 +574,29 @@ export default function ProviderLimits() {
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || config.label}
{conn.name || conn.displayName || conn.email || config.label}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<div className="flex items-center gap-1.5 mt-1 min-h-5">
<span
title={
quota?.plan
? t("rawPlanWithValue", { plan: quota.plan })
resolvedPlan
? t("rawPlanWithValue", { plan: resolvedPlan })
: t("noPlanFromProvider")
}
className="inline-flex items-center shrink-0"
>
<Badge variant={tierMeta.variant} size="sm" dot>
<Badge
variant={tierMeta.variant}
size="sm"
dot
className="h-5 leading-none"
>
{tierMeta.label}
</Badge>
</span>
<span className="text-[11px] text-text-muted">{config.label}</span>
<span className="text-[11px] leading-none text-text-muted">
{config.label}
</span>
</div>
</div>
</div>
@@ -597,17 +621,19 @@ export default function ProviderLimits() {
<div className="text-xs text-text-muted italic">{quota.message}</div>
) : quota?.quotas?.length > 0 ? (
quota.quotas.map((q, i) => {
const remaining =
q.remainingPercentage !== undefined
? Math.round(q.remainingPercentage)
: calculatePercentage(q.used, q.total);
const colors = getBarColor(remaining);
const remainingPercentage = calculatePercentage(q.used, q.total);
const colors = getBarColor(remainingPercentage);
const cd = formatCountdown(q.resetAt);
const shortName = getShortModelName(q.name);
const staleAfterReset = q.staleAfterReset === true;
return (
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
<div
key={i}
className={`flex items-center gap-1.5 min-w-[200px] shrink-0 ${
i > 0 ? "border-l border-border/80 pl-3 ml-1" : ""
}`}
>
{/* Model label */}
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
@@ -632,7 +658,7 @@ export default function ProviderLimits() {
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remaining, 100)}%`,
width: `${Math.min(remainingPercentage, 100)}%`,
background: colors.bar,
}}
/>
@@ -643,7 +669,7 @@ export default function ProviderLimits() {
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remaining}%
{remainingPercentage}%
</span>
</div>
);
@@ -1,6 +1,30 @@
import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts";
import { safePercentage } from "@/shared/utils/formatting";
const PROVIDER_PLAN_FALLBACKS = new Set([
"claude code",
"kimi coding",
"kiro",
"openai codex",
"codex",
"github copilot",
]);
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function normalizePlanCandidate(value: unknown) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.toLowerCase() === "unknown") return null;
if (PROVIDER_PLAN_FALLBACKS.has(trimmed.toLowerCase())) return null;
return trimmed;
}
/**
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
* @param {string|Date} date - ISO date string or Date object
@@ -180,7 +204,6 @@ export function parseQuotaData(provider, data) {
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
@@ -210,9 +233,32 @@ export function parseQuotaData(provider, data) {
return normalizedQuotas;
}
/**
* Resolve the best available plan label using live usage first, then persisted
* provider-specific connection metadata.
*/
export function resolvePlanValue(plan, providerSpecificData) {
const psd = toRecord(providerSpecificData);
const candidates = [
plan,
psd.workspacePlanType,
psd.plan,
psd.subscription,
psd.tier,
psd.accountTier,
];
for (const candidate of candidates) {
const normalized = normalizePlanCandidate(candidate);
if (normalized) return normalized;
}
return null;
}
/**
* Normalize provider-specific plan labels into a shared tier taxonomy.
* Supported tiers: enterprise, business, team, ultra, pro, free, unknown.
* Supported tiers: enterprise, business, team, ultra, pro, plus, free, unknown.
*/
export function normalizePlanTier(plan) {
const raw = typeof plan === "string" ? plan.trim() : "";
@@ -223,12 +269,12 @@ export function normalizePlanTier(plan) {
const upper = raw.toUpperCase();
// Provider names that are not real plan tiers — treat as unknown
if (upper === "CLAUDE CODE" || upper === "KIMI CODING" || upper === "KIRO") {
return { key: "unknown", label: raw, variant: "default", rank: 0, raw };
if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) {
return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw };
}
if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) {
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw };
}
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
@@ -245,7 +291,7 @@ export function normalizePlanTier(plan) {
}
if (upper.includes("STUDENT")) {
return { key: "pro", label: "Student", variant: "primary", rank: 3, raw };
return { key: "pro", label: "Student", variant: "success", rank: 3, raw };
}
if (upper.includes("ULTRA")) {
@@ -253,11 +299,11 @@ export function normalizePlanTier(plan) {
}
if (upper.includes("PRO") || upper.includes("PREMIUM")) {
return { key: "pro", label: "Pro", variant: "primary", rank: 3, raw };
return { key: "pro", label: "Pro", variant: "success", rank: 3, raw };
}
if (upper.includes("PLUS") || upper.includes("PAID")) {
return { key: "plus", label: "Plus", variant: "secondary", rank: 2, raw };
return { key: "plus", label: "Plus", variant: "success", rank: 2, raw };
}
if (
+67 -8
View File
@@ -1,7 +1,18 @@
import { NextResponse } from "next/server";
import { getCacheStats, clearCache, cleanExpiredEntries } from "@/lib/semanticCache";
import { NextRequest, NextResponse } from "next/server";
import {
getCacheStats,
clearCache,
cleanExpiredEntries,
invalidateByModel,
invalidateBySignature,
invalidateStale,
} from "@/lib/semanticCache";
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* GET /api/cache Cache statistics
*/
@@ -15,19 +26,67 @@ export async function GET() {
idempotency: idempotencyStats,
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: errorMessage(error) }, { status: 500 });
}
}
/**
* DELETE /api/cache Clear all caches
* DELETE /api/cache Clear all caches or targeted invalidation.
*
* Exactly one optional query parameter may be provided:
* ?model=<name> invalidate all entries for a specific model
* ?signature=<hex> invalidate a single entry by its SHA-256 signature
* ?staleMs=<number> invalidate entries older than N milliseconds
* (no params) clear all cache entries
*
* Providing more than one parameter returns 400 Bad Request.
*/
export async function DELETE() {
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const model = searchParams.get("model");
const signature = searchParams.get("signature");
const staleMsParam = searchParams.get("staleMs");
// Enforce mutual exclusivity — only one invalidation mode per request
const paramCount = [model, signature, staleMsParam].filter(Boolean).length;
if (paramCount > 1) {
return NextResponse.json(
{
error:
"Only one invalidation parameter (model, signature, or staleMs) may be provided per request.",
},
{ status: 400 }
);
}
if (model) {
const removed = invalidateByModel(model);
return NextResponse.json({ ok: true, invalidated: removed, scope: "model", model });
}
if (signature) {
const removed = invalidateBySignature(signature);
return NextResponse.json({ ok: true, invalidated: removed ? 1 : 0, scope: "signature" });
}
if (staleMsParam) {
const maxAgeMs = parseInt(staleMsParam, 10);
if (Number.isNaN(maxAgeMs) || maxAgeMs <= 0) {
return NextResponse.json(
{ error: "staleMs must be a positive integer (milliseconds)." },
{ status: 400 }
);
}
const removed = invalidateStale(maxAgeMs);
return NextResponse.json({ ok: true, invalidated: removed, scope: "stale", maxAgeMs });
}
// Full clear
clearCache();
const cleaned = cleanExpiredEntries();
return NextResponse.json({ ok: true, expiredRemoved: cleaned });
const expiredRemoved = cleanExpiredEntries();
return NextResponse.json({ ok: true, expiredRemoved, scope: "all" });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ error: errorMessage(error) }, { status: 500 });
}
}
+31 -7
View File
@@ -1,4 +1,9 @@
import { NextResponse } from "next/server";
import {
buildComboTestRequestBody,
probeComboModelReachability,
shouldProbeComboTestReachability,
} from "@/lib/combos/testHealth";
import { getComboByName } from "@/lib/localDb";
import { testComboSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -49,13 +54,9 @@ export async function POST(request) {
const startTime = Date.now();
try {
// Send a minimal chat request to the internal SSE handler
// Use OpenAI-compatible format — universally accepted by all providers via the translator
const testBody = {
model: modelStr,
messages: [{ role: "user", content: "Hi" }],
max_tokens: 5,
stream: false,
};
// Use a tiny but realistic request body so gateway-routed models do not
// get flagged as dead just because the probe payload is too synthetic.
const testBody = buildComboTestRequestBody(modelStr);
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const controller = new AbortController();
@@ -88,6 +89,29 @@ export async function POST(request) {
} catch {
errorMsg = res.statusText;
}
let reachability = null;
if (shouldProbeComboTestReachability(res.status)) {
try {
reachability = await probeComboModelReachability(modelStr);
} catch {
reachability = null;
}
}
if (reachability?.reachable) {
results.push({
model: modelStr,
status: "reachable",
statusCode: res.status,
error: errorMsg,
latencyMs,
provider: reachability.provider,
probeMethod: reachability.method,
});
continue;
}
results.push({
model: modelStr,
status: "error",
+7 -10
View File
@@ -1,10 +1,9 @@
/**
* 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
* GET /api/logs/detail List detailed request logs + current enabled flag
* POST /api/logs/detail Enable/disable detailed logging
*/
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import {
getRequestDetailLogs,
getRequestDetailLogCount,
@@ -15,9 +14,8 @@ 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 authError = await requireManagementAuth(req);
if (authError) return authError;
const url = new URL(req.url);
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
@@ -31,9 +29,8 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
if (!isAuthenticated(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const authError = await requireManagementAuth(req);
if (authError) return authError;
const body = await req.json();
const enabled = body.enabled === true || body.enabled === "1";
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile";
function toErrorResponse(error: unknown) {
if (error instanceof CodexAuthFileError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
},
{ status: error.status }
);
}
const message = error instanceof Error ? error.message : "Failed to apply Codex auth file";
return NextResponse.json({ error: message }, { status: 500 });
}
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard, code: "writes_disabled" }, { status: 403 });
}
const { id } = await params;
const result = await writeCodexAuthFileToLocalCli(id);
return NextResponse.json({
success: true,
connectionId: id,
connectionLabel: result.connectionLabel,
authPath: result.authPath,
writtenAt: new Date().toISOString(),
});
} catch (error) {
console.error("[Codex Auth Apply] Failed:", error);
return toErrorResponse(error);
}
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile";
function toErrorResponse(error: unknown) {
if (error instanceof CodexAuthFileError) {
return NextResponse.json(
{
error: error.message,
code: error.code,
},
{ status: error.status }
);
}
const message = error instanceof Error ? error.message : "Failed to export Codex auth file";
return NextResponse.json({ error: message }, { status: 500 });
}
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const built = await buildCodexAuthFile(id);
return new Response(built.content, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${built.fileName}"`,
"Cache-Control": "no-store, max-age=0",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
console.error("[Codex Auth Export] Failed:", error);
return toErrorResponse(error);
}
}
+35 -8
View File
@@ -5,6 +5,7 @@ import {
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { getModelIsHidden } from "@/lib/localDb";
type JsonRecord = Record<string, unknown>;
@@ -58,6 +59,8 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
antigravity: () => [
{ 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-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ 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" },
@@ -140,7 +143,7 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
})),
},
qwen: {
url: "https://portal.qwen.ai/v1/models",
url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
@@ -317,14 +320,31 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"opencode-zen": {
url: "https://opencode.ai/zen/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
};
/**
* GET /api/providers/[id]/models - Get models list from provider
*/
export async function GET(request, { params }) {
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> | { id: string } }
) {
try {
const { id } = await params;
const params = await context.params;
const { id } = params;
// Check if we should exclude hidden models (used by MCP tools to prevent hidden model leaks)
const { searchParams } = new URL(request.url);
const excludeHidden = searchParams.get("excludeHidden") === "true";
const connection = await getProviderConnectionById(id);
if (!connection) {
@@ -339,6 +359,13 @@ export async function GET(request, { params }) {
return NextResponse.json({ error: "Invalid connection provider" }, { status: 400 });
}
const buildResponse = (payload: any, statusConfig?: ResponseInit) => {
if (excludeHidden && payload.models && Array.isArray(payload.models)) {
payload.models = payload.models.filter((m: any) => !getModelIsHidden(provider, m.id));
}
return NextResponse.json(payload, statusConfig);
};
const connectionId = typeof connection.id === "string" ? connection.id : id;
const apiKey = typeof connection.apiKey === "string" ? connection.apiKey : "";
const accessToken = typeof connection.accessToken === "string" ? connection.accessToken : "";
@@ -423,7 +450,7 @@ export async function GET(request, { params }) {
? "local_catalog"
: "api";
return NextResponse.json({
return buildResponse({
provider,
connectionId,
models,
@@ -435,7 +462,7 @@ export async function GET(request, { params }) {
}
if (provider === "claude") {
return NextResponse.json({
return buildResponse({
provider,
connectionId,
models: STATIC_MODEL_PROVIDERS.claude(),
@@ -480,7 +507,7 @@ export async function GET(request, { params }) {
const data = await response.json();
const models = data.data || data.models || [];
return NextResponse.json({
return buildResponse({
provider,
connectionId,
models,
@@ -493,7 +520,7 @@ export async function GET(request, { params }) {
? STATIC_MODEL_PROVIDERS[provider as keyof typeof STATIC_MODEL_PROVIDERS]
: undefined;
if (staticModelsFn) {
return NextResponse.json({
return buildResponse({
provider,
connectionId,
models: staticModelsFn(),
@@ -559,7 +586,7 @@ export async function GET(request, { params }) {
const data = await response.json();
const models = config.parseResponse(data);
return NextResponse.json({
return buildResponse({
provider,
connectionId,
models,
@@ -3,6 +3,10 @@ import { getProviderConnectionById } from "@/models";
import { replaceCustomModels } from "@/lib/db/models";
import { saveCallLog } from "@/lib/usage/callLogs";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
buildModelSyncInternalHeaders,
isModelSyncInternalRequest,
} from "@/shared/services/modelSyncScheduler";
/**
* POST /api/providers/[id]/sync-models
@@ -19,7 +23,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const { id } = await params;
try {
if (!(await isAuthenticated(request))) {
if (!(await isAuthenticated(request)) && !isModelSyncInternalRequest(request)) {
return NextResponse.json(
{ error: { message: "Authentication required", type: "invalid_api_key" } },
{ status: 401 }
@@ -41,7 +45,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
method: "GET",
headers: {
cookie: request.headers.get("cookie") || "",
"x-internal": "model-sync",
...buildModelSyncInternalHeaders(),
},
});
+1 -1
View File
@@ -59,7 +59,7 @@ const OAUTH_TEST_CONFIG = {
refreshable: true,
},
qwen: {
// portal.qwen.ai/v1/models returns 404 — endpoint no longer exists.
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
// Use checkExpiry instead — actual connectivity is validated via real requests.
checkExpiry: true,
refreshable: true,
+5
View File
@@ -114,6 +114,11 @@ export async function PATCH(request) {
setCliCompatProviders(body.cliCompatProviders || []);
}
if ("maxCallLogs" in body) {
const { invalidateCallLogsMaxCache } = await import("@/lib/usage/callLogs");
invalidateCallLogsMaxCache();
}
const { password, ...safeSettings } = settings;
return NextResponse.json(safeSettings);
} catch (error) {
+14 -2
View File
@@ -126,11 +126,23 @@ export async function GET(
return Response.json({ error: "Connection not found" }, { status: 404 });
}
// Only OAuth connections have usage APIs
if (connection.authType !== "oauth") {
// Only OAuth connections and specific API key providers have usage APIs
const apikeyUsageProviders = ["glm"];
if (connection.authType !== "oauth" && !apikeyUsageProviders.includes(connection.provider)) {
return Response.json({ message: "Usage not available for API key connections" });
}
// API key providers skip OAuth refresh — call usage fetcher directly
if (connection.authType !== "oauth") {
try {
const usageData = await getUsageForProvider(connection);
return Response.json(usageData);
} catch (error) {
console.error("[Usage API] Error fetching usage:", error);
return Response.json({ error: (error as any).message }, { status: 500 });
}
}
// Resolve proxy for this connection FIRST (key → combo → provider → global → direct)
// so that both credential refresh AND usage fetch go through the proxy.
const proxyInfo = await resolveProxyForConnection(connectionId);
@@ -1,8 +1,12 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getCallLogById } from "@/lib/usageDb";
export async function GET(request, { params }) {
try {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { id } = await params;
const log = await getCallLogById(id);
+4
View File
@@ -1,8 +1,12 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getCallLogs } from "@/lib/usageDb";
export async function GET(request: Request) {
try {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { searchParams } = new URL(request.url);
const filter: Record<string, any> = {};
+2 -2
View File
@@ -1,5 +1,5 @@
import { CORS_ORIGIN, CORS_HEADERS } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
@@ -75,7 +75,7 @@ export async function POST(request: Request) {
headers: request.headers,
body: JSON.stringify(normalized),
});
return await handleChat(newRequest);
return await handleChat(newRequest, buildClientRawRequest(request, body));
}
}
} catch (error) {
+5 -1
View File
@@ -7,6 +7,7 @@ import {
getAllCustomModels,
getSettings,
getProviderNodes,
getModelIsHidden,
} from "@/lib/localDb";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
@@ -223,7 +224,7 @@ export async function getUnifiedModelsResponse(
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false) continue;
if (combo.isActive === false || combo.isHidden === true) continue;
models.push({
id: combo.name,
object: "model",
@@ -255,6 +256,8 @@ export async function getUnifiedModelsResponse(
for (const model of providerModels) {
const aliasId = `${alias}/${model.id}`;
if (getModelIsHidden(canonicalProviderId, model.id)) continue;
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
// Model-level context length overrides provider default
@@ -416,6 +419,7 @@ export async function getUnifiedModelsResponse(
for (const model of providerCustomModels) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId) continue;
if (model.isHidden === true) continue;
// Skip if already added as built-in
const aliasId = `${alias}/${modelId}`;
@@ -1,5 +1,5 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -91,5 +91,5 @@ export async function POST(request, { params }) {
body: JSON.stringify(body),
});
return await handleChat(newRequest);
return await handleChat(newRequest, buildClientRawRequest(request, rawBody));
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleChat } from "@/sse/handlers/chat";
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -87,7 +87,7 @@ export async function POST(request, { params }) {
body: JSON.stringify(convertedBody),
});
return await handleChat(newRequest);
return await handleChat(newRequest, buildClientRawRequest(request, rawBody));
} catch (error) {
console.log("Error handling Gemini request:", error);
return Response.json({ error: { message: error.message, code: 500 } }, { status: 500 });
+1 -1
View File
@@ -219,7 +219,7 @@ body {
/* Material Symbols */
.material-symbols-outlined {
font-family: "Material Symbols Outlined", sans-serif;
font-family: "Material Symbols Outlined", sans-serif !important;
font-weight: normal;
font-style: normal;
font-size: 24px;
+2
View File
@@ -27,6 +27,7 @@ export const LOCALES = [
"sk",
"sv",
"th",
"tr",
"uk-UA",
"vi",
"zh-CN",
@@ -68,6 +69,7 @@ export const LANGUAGES: readonly {
{ code: "sk", label: "SK", name: "Slovenčina", flag: "🇸🇰" },
{ code: "sv", label: "SV", name: "Svenska", flag: "🇸🇪" },
{ code: "th", label: "TH", name: "ไทย", flag: "🇹🇭" },
{ code: "tr", label: "TR", name: "Türkçe", flag: "🇹🇷" },
{ code: "uk-UA", label: "UK-UA", name: "Українська", flag: "🇺🇦" },
{ code: "vi", label: "VI", name: "Tiếng Việt", flag: "🇻🇳" },
{ code: "zh-CN", label: "ZH-CN", name: "中文 (简体)", flag: "🇨🇳" },
+198 -6
View File
@@ -58,7 +58,85 @@
"free": "مجاني",
"skipToContent": "انتقل إلى المحتوى",
"maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.",
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting..."
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting...",
"accept": "قبول",
"accountId": "معرف الحساب",
"alias": "الاسم المستعار",
"apiKeyId": "معرف مفتاح واجهة برمجة التطبيقات",
"apiKeyName": "اسم مفتاح واجهة برمجة التطبيقات",
"apiKeySecret": "سر مفتاح API",
"authorization": "إذن",
"content-type": "نوع المحتوى",
"content-length": "طول المحتوى",
"cookie": "ملف تعريف الارتباط",
"file": "ملف",
"host": "المضيف",
"id": "معرف",
"import": "استيراد",
"limit": "الحد",
"offset": "إزاحة",
"open": "مفتوح",
"origin": "الأصل",
"promptTokens": "الرموز الفورية",
"completionTokens": "رموز الإنجاز",
"totalTokens": "مجموع الرموز",
"rawModel": "النموذج الخام",
"scope": "النطاق",
"skill": "مهارة",
"sortBy": "فرز حسب",
"sortOrder": "ترتيب الفرز",
"tab": "علامة التبويب",
"text": "نص",
"textarea": "منطقة النص",
"tool": "أداة",
"toolId": "معرف الأداة",
"web": "ويب",
"whereUsed": "حيث تستخدم",
"whitelist": "القائمة البيضاء",
"blacklist": "القائمة السوداء",
"resolve": "حل",
"force": "القوة",
"base64url": "عنوان URL Base64",
"hex": "عرافة",
"range": "النطاق",
"component": "مكون",
"redirect_uri": "إعادة توجيه URI",
"idempotency-key": "مفتاح العجز",
"error_description": "وصف الخطأ",
"code": "الكود",
"compatible": "متوافق",
"chat-completions": "استكمالات الدردشة",
"oauth": "OAuth",
"auth_token": "رمز المصادقة",
"crypto": "تشفير",
"hours": "ساعات",
"selfsigned": "موقعة ذاتيا",
"proxy_id": "معرف الوكيل",
"proxyId": "معرف الوكيل",
"connectionId": "معرف الاتصال",
"resolveConnectionId": "حل معرف الاتصال",
"resolve_connection_id": "حل معرف الاتصال",
"scope_id": "معرف النطاق",
"scopeId": "معرف النطاق",
"jwtSecret": "JWT سر",
"keytar": "كيتار",
"better-sqlite3": "أفضل-sqlite3",
"undici": "undici",
"builder-id": "معرف البناء",
"musicDesc": "وصف الموسيقى",
"musicGeneration": "جيل الموسيقى",
"idc": "آي دي سي",
"cloud-status-changed": "تغيرت حالة السحابة",
"where_used": "حيث تستخدم",
"windowMs": "النافذة (مللي ثانية)",
"social-github": "جيثب",
"social-google": "جوجل",
"TOOL_ALLOWLIST": "القائمة المسموح بها للأداة",
"TOOL_DENYLIST": "قائمة رفض الأداة",
"Failed to save pricing": "فشل حفظ الأسعار",
"Failed to reset pricing": "فشل في إعادة تعيين التسعير",
"apikey": "مفتاح واجهة برمجة التطبيقات",
"http": "HTTP"
},
"sidebar": {
"home": "الصفحة الرئيسية",
@@ -107,7 +185,9 @@
"agents": "وكلاء",
"cliToolsShort": "أدوات",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "المواضيع",
@@ -179,7 +259,11 @@
"requestsShort": "{count} طلب",
"providerModelsTitle": "{provider} - النماذج",
"copiedModel": "تم النسخ: {model}",
"aliasLabel": "الاسم المستعار"
"aliasLabel": "الاسم المستعار",
"updateNow": "التحديث الآن",
"updating": "جارٍ التحديث...",
"updateAvailableDesc": "نسخة جديدة متاحة. انقر للتحديث.",
"updateStarted": "بدأ التحديث..."
},
"analytics": {
"title": "التحليلات",
@@ -535,6 +619,9 @@
"title": "إضافة تكوين النموذج",
"desc": "أضف التكوين التالي إلى مجموعة النماذج الخاصة بك:"
}
},
"notes": {
"0": "متابعة يستخدم ملف التكوين JSON."
}
},
"opencode": {
@@ -553,6 +640,10 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "يتطلب OpenCode تكوين مفتاح API.",
"1": "قم بتعيين عنوان URL الأساسي إلى نقطة نهاية OmniRoute الخاصة بك."
}
},
"kiro": {
@@ -571,6 +662,9 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "يتطلب كيرو حساب أمازون."
}
}
},
@@ -1314,6 +1408,10 @@
"chatCompletions": "استكمالات الدردشة",
"importingModels": "جارٍ الاستيراد...",
"importFromModels": "الاستيراد من / النماذج",
"clearAllModels": "مسح جميع النماذج",
"clearAllModelsConfirm": "هل أنت متأكد أنك تريد إزالة جميع النماذج لهذا المزود؟ لا يمكن التراجع عن هذا.",
"clearAllModelsSuccess": "تم مسح جميع النماذج",
"clearAllModelsFailed": "فشل في مسح النماذج",
"addConnectionToImport": "أضف اتصالاً لتمكين الاستيراد.",
"noModelsConfigured": "لم يتم تكوين أي نماذج",
"connectionCount": "{count} الاتصال (الاتصالات)",
@@ -1477,7 +1575,16 @@
"compatUpstreamHeaderValue": "Value",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamRemoveRow": "Remove row"
"compatUpstreamRemoveRow": "Remove row",
"autoSync": "المزامنة التلقائية",
"autoSyncTooltip": "تحديث قائمة النماذج تلقائيًا كل 24 ساعة (قابلة للتكوين عبر MODEL_SYNC_INTERVAL_HOURS)",
"autoSyncEnabled": "تم تمكين المزامنة التلقائية — سيتم تحديث النماذج بشكل دوري",
"autoSyncDisabled": "تم تعطيل المزامنة التلقائية",
"autoSyncToggleFailed": "فشل في تبديل المزامنة التلقائية",
"clearAllModels": "مسح كافة النماذج",
"clearAllModelsConfirm": "هل أنت متأكد أنك تريد إزالة كافة النماذج لهذا الموفر؟ لا يمكن التراجع عن هذا.",
"clearAllModelsSuccess": "تم مسح جميع النماذج",
"clearAllModelsFailed": "فشل في مسح النماذج"
},
"settings": {
"title": "الإعدادات",
@@ -2304,7 +2411,15 @@
"restartServerWithNewPassword": "أعد تشغيل الخادم وسيتم استخدام كلمة المرور الجديدة",
"backToLogin": "العودة إلى تسجيل الدخول",
"forgotPassword": "هل نسيت كلمة المرور؟",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"Authorization": "إذن",
"Content-Disposition": "التصرف في المحتوى",
"waitingForAuthorization": "في انتظار الترخيص...",
"waitingForGoogleAuthorization": "في انتظار إذن Google...",
"waitingForOpenAIAuthorization": "في انتظار ترخيص OpenAI...",
"waitingForAntigravityAuthorization": "في انتظار تصريح مكافحة الجاذبية...",
"waitingForIFlowAuthorization": "في انتظار ترخيص iFlow...",
"exchangingCodeForTokens": "استبدال الكود بالرموز..."
},
"landing": {
"brandName": "OmniRoute",
@@ -2511,7 +2626,9 @@
"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."
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"endpointSpeechNote": "توليد تحويل النص إلى كلام (ElevenLabs، OpenAI TTS).",
"endpointEmbeddingsNote": "إنشاء تضمين النص (OpenAI، Cohere، Voyage)."
},
"legal": {
"privacyPolicy": "سياسة الخصوصية",
@@ -2689,5 +2806,80 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"templateNames": {
"simple-chat": "دردشة بسيطة",
"streaming": "الجري",
"system-prompt": "موجه النظام",
"thinking": "التفكير",
"tool-calling": "استدعاء الأداة",
"multi-turn": "متعدد المنعطفات"
},
"templateDescriptions": {
"simple-chat": "قالب الدردشة الأساسي مع رسالة النظام",
"streaming": "نموذج لتدفق الردود",
"system-prompt": "قالب مع موجه النظام المخصص",
"thinking": "قالب مع ميزانية التفكير/الاستدلال",
"tool-calling": "نموذج لاستدعاء الأداة/الوظيفة",
"multi-turn": "قالب للمحادثات متعددة المنعطفات"
},
"templatePayloads": {
"simpleChat": {
"system": "أنت مساعد AI مفيد.",
"userGreeting": "مرحبا! كيف يمكنني مساعدتك اليوم؟"
},
"streaming": {
"prompt": "اكتب قصة عنه"
},
"systemPrompt": {
"question": "ما هو معنى الحياة؟",
"systemInstruction": "تقديم إجابة مدروسة وفلسفية."
},
"thinking": {
"question": "شرح الحوسبة الكمومية"
},
"toolCalling": {
"cityNameDescription": "اسم المدينة لمعرفة الطقس لها",
"toolDescription": "الحصول على الطقس الحالي لموقع ما",
"userWeather": "ما هو الطقس في طوكيو ؟"
},
"multiTurn": {
"system": "أنت مساعد مفيد.",
"assistantExample": "سأكون سعيدًا بمساعدتك في ذلك.",
"userInitial": "انا بحاجة الى مساعدة مع",
"userFollowUp": "هل يمكنك توضيح ذلك؟"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+198 -6
View File
@@ -58,7 +58,85 @@
"free": "безплатно",
"skipToContent": "Преминете към съдържанието",
"maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.",
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting..."
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting...",
"accept": "Приеми",
"accountId": "ID на акаунта",
"alias": "Псевдоним",
"apiKeyId": "ID на API ключ",
"apiKeyName": "Име на API ключ",
"apiKeySecret": "API Key Secret",
"authorization": "Упълномощаване",
"content-type": "Тип съдържание",
"content-length": "Дължина на съдържанието",
"cookie": "бисквитка",
"file": "Файл",
"host": "Домакин",
"id": "ID",
"import": "Импортиране",
"limit": "Лимит",
"offset": "Офсет",
"open": "Отворете",
"origin": "Произход",
"promptTokens": "Подкани токени",
"completionTokens": "Токени за завършване",
"totalTokens": "Общо токени",
"rawModel": "Суров модел",
"scope": "Обхват",
"skill": "Умение",
"sortBy": "Сортиране по",
"sortOrder": "Ред на сортиране",
"tab": "Таб",
"text": "Текст",
"textarea": "Текстово поле",
"tool": "Инструмент",
"toolId": "ID на инструмента",
"web": "Мрежа",
"whereUsed": "Къде се използва",
"whitelist": "Бял списък",
"blacklist": "Черен списък",
"resolve": "Разрешете",
"force": "Сила",
"base64url": "Base64 URL",
"hex": "шестнадесетичен",
"range": "Обхват",
"component": "Компонент",
"redirect_uri": "URI за пренасочване",
"idempotency-key": "Ключ за идемпотентност",
"error_description": "Описание на грешката",
"code": "Код",
"compatible": "Съвместим",
"chat-completions": "Чат завършвания",
"oauth": "OAuth",
"auth_token": "Токен за удостоверяване",
"crypto": "Крипто",
"hours": "часове",
"selfsigned": "Самоподписан",
"proxy_id": "ID на прокси",
"proxyId": "ID на прокси",
"connectionId": "ID на връзката",
"resolveConnectionId": "Разрешете ИД на връзката",
"resolve_connection_id": "Разрешете ИД на връзката",
"scope_id": "ID на обхвата",
"scopeId": "ID на обхвата",
"jwtSecret": "JWT Secret",
"keytar": "Keytar",
"better-sqlite3": "по-добре-sqlite3",
"undici": "undici",
"builder-id": "ID на строителя",
"musicDesc": "Описание на музиката",
"musicGeneration": "Музикално поколение",
"idc": "IDC",
"cloud-status-changed": "Състоянието на облака е променено",
"where_used": "Къде се използва",
"windowMs": "Прозорец (ms)",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Списък с разрешени инструменти",
"TOOL_DENYLIST": "Списък за отказ на инструмента",
"Failed to save pricing": "Неуспешно запазване на цената",
"Failed to reset pricing": "Неуспешно нулиране на цените",
"apikey": "API ключ",
"http": "HTTP"
},
"sidebar": {
"home": "Начало",
@@ -107,7 +185,9 @@
"agents": "Агенти",
"cliToolsShort": "Инструменти",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Теми",
@@ -179,7 +259,11 @@
"requestsShort": "{count} изискване",
"providerModelsTitle": "{provider} - Модели",
"copiedModel": "Копирано: {model}",
"aliasLabel": "псевдоним"
"aliasLabel": "псевдоним",
"updateNow": "Актуализирайте сега",
"updating": "Актуализиране...",
"updateAvailableDesc": "Налична е нова версия. Кликнете, за да актуализирате.",
"updateStarted": "Актуализацията започна..."
},
"analytics": {
"title": "Анализ",
@@ -535,6 +619,9 @@
"title": "Добавяне на конфигурация на модела",
"desc": "Добавете следната конфигурация към вашия масив от модели:"
}
},
"notes": {
"0": "Продължаване използва JSON конфигурационен файл."
}
},
"opencode": {
@@ -553,6 +640,10 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "OpenCode изисква конфигурация на API ключ.",
"1": "Задайте основния URL адрес на вашата крайна точка OmniRoute."
}
},
"kiro": {
@@ -571,6 +662,9 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "Киро изисква акаунт в Amazon."
}
}
},
@@ -1314,6 +1408,10 @@
"chatCompletions": "Чат завършвания",
"importingModels": "Импортиране...",
"importFromModels": "Импортиране от /models",
"clearAllModels": "Изчисти всички модели",
"clearAllModelsConfirm": "Сигурни ли сте, че искате да премахнете всички модели за този доставчик? Това не може да бъде отменено.",
"clearAllModelsSuccess": "Всички модели са изчистени",
"clearAllModelsFailed": "Неуспешно изчистване на моделите",
"addConnectionToImport": "Добавете връзка, за да активирате импортирането.",
"noModelsConfigured": "Няма конфигурирани модели",
"connectionCount": "{count} връзка(и)",
@@ -1477,7 +1575,16 @@
"compatUpstreamHeaderValue": "Value",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamRemoveRow": "Remove row"
"compatUpstreamRemoveRow": "Remove row",
"autoSync": "Автоматично синхронизиране",
"autoSyncTooltip": "Автоматично опресняване на списъка с модели на всеки 24 часа (може да се конфигурира чрез MODEL_SYNC_INTERVAL_HOURS)",
"autoSyncEnabled": "Автоматичното синхронизиране е активирано — моделите ще се опресняват периодично",
"autoSyncDisabled": "Автоматичното синхронизиране е деактивирано",
"autoSyncToggleFailed": "Неуспешно превключване на автоматичното синхронизиране",
"clearAllModels": "Изчистване на всички модели",
"clearAllModelsConfirm": "Сигурни ли сте, че искате да премахнете всички модели за този доставчик? Това не може да бъде отменено.",
"clearAllModelsSuccess": "Всички модели изчистени",
"clearAllModelsFailed": "Неуспешно изчистване на моделите"
},
"settings": {
"title": "Настройки",
@@ -2304,7 +2411,15 @@
"restartServerWithNewPassword": "Рестартирайте сървъра - той ще използва новата парола",
"backToLogin": "Назад към Вход",
"forgotPassword": "Забравена парола?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"Authorization": "Упълномощаване",
"Content-Disposition": "Съдържание-разположение",
"waitingForAuthorization": "Чака се оторизация...",
"waitingForGoogleAuthorization": "Изчаква се разрешение от Google...",
"waitingForOpenAIAuthorization": "Изчаква се разрешение за OpenAI...",
"waitingForAntigravityAuthorization": "Изчаква се разрешение за антигравитация...",
"waitingForIFlowAuthorization": "Изчаква се разрешение за iFlow...",
"exchangingCodeForTokens": "Размяна на код за токени..."
},
"landing": {
"brandName": "OmniRoute",
@@ -2511,7 +2626,9 @@
"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."
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"endpointSpeechNote": "Генериране на текст към реч (ElevenLabs, OpenAI TTS).",
"endpointEmbeddingsNote": "Генериране на вграждане на текст (OpenAI, Cohere, Voyage)."
},
"legal": {
"privacyPolicy": "Политика за поверителност",
@@ -2689,5 +2806,80 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"templateNames": {
"simple-chat": "Обикновен чат",
"streaming": "Поточно предаване",
"system-prompt": "Системен ред",
"thinking": "Мислене",
"tool-calling": "Извикване на инструмент",
"multi-turn": "Многооборотен"
},
"templateDescriptions": {
"simple-chat": "Основен шаблон за чат със системно съобщение",
"streaming": "Шаблон за поточно предаване на отговори",
"system-prompt": "Шаблон с персонализирана системна подкана",
"thinking": "Шаблон с бюджет за разсъждения/мислене",
"tool-calling": "Шаблон за извикване на инструмент/функция",
"multi-turn": "Шаблон за многооборотни разговори"
},
"templatePayloads": {
"simpleChat": {
"system": "Вие сте полезен AI помощник.",
"userGreeting": "здравей Как мога да ви помогна днес?"
},
"streaming": {
"prompt": "Напишете история за"
},
"systemPrompt": {
"question": "Какъв е смисълът на живота?",
"systemInstruction": "Дайте обмислен, философски отговор."
},
"thinking": {
"question": "Обяснете квантовите изчисления"
},
"toolCalling": {
"cityNameDescription": "Името на града, за който да получите прогноза за времето",
"toolDescription": "Вземете текущото време за местоположение",
"userWeather": "Какво е времето в Токио?"
},
"multiTurn": {
"system": "Вие сте полезен помощник.",
"assistantExample": "Ще се радвам да ви помогна с това.",
"userInitial": "Имам нужда от помощ за",
"userFollowUp": "Можете ли да разкажете по-подробно за това?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+69 -2
View File
@@ -185,7 +185,9 @@
"themeViolet": "Fialová",
"themeOrange": "Oranžová",
"themeCyan": "Azurová",
"cliToolsShort": "Nástroje"
"cliToolsShort": "Nástroje",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Motivy",
@@ -1470,6 +1472,10 @@
"chatCompletions": "Chat Completions",
"importingModels": "Importuji...",
"importFromModels": "Import z /models",
"clearAllModels": "Vymazat všechny modely",
"clearAllModelsConfirm": "Opravdu chcete odebrat všechny modely pro tohoto poskytovatele? Tuto akci nelze vrátit.",
"clearAllModelsSuccess": "Všechny modely vymazány",
"clearAllModelsFailed": "Nepodařilo se vymazat modely",
"addConnectionToImport": "Přidejte připojení pro povolení importu.",
"noModelsConfigured": "Žádné nastavené modely",
"connectionCount": "{count} připojení",
@@ -2474,7 +2480,9 @@
"waitingForOpenAIAuthorization": "Čekám na OpenAI autorizaci...",
"waitingForAntigravityAuthorization": "Čekám na Antigravity autorizaci...",
"waitingForIFlowAuthorization": "Čekám na iFlow autorizaci...",
"exchangingCodeForTokens": "Vyměňuji kód za tokeny..."
"exchangingCodeForTokens": "Vyměňuji kód za tokeny...",
"Authorization": "Autorizace",
"Content-Disposition": "Obsah-Dispozice"
},
"landing": {
"brandName": "OmniRoute",
@@ -2814,5 +2822,64 @@
"thinking": "Šablona s rozpočtem na přemýšlení/odůvodňování",
"tool-calling": "Šablona pro volání nástrojů/funkcí",
"multi-turn": "Šablona pro konverzace s Multitahy"
},
"templatePayloads": {
"simpleChat": {
"system": "Jste užitečný asistent AI.",
"userGreeting": "Dobrý den! Jak vám dnes mohu pomoci?"
},
"streaming": {
"prompt": "Napište příběh o"
},
"systemPrompt": {
"question": "Jaký je smysl života?",
"systemInstruction": "Poskytněte promyšlenou, filozofickou odpověď."
},
"thinking": {
"question": "Vysvětlete kvantové výpočty"
},
"toolCalling": {
"cityNameDescription": "Název města, pro které se má zjistit počasí",
"toolDescription": "Získejte aktuální počasí pro místo",
"userWeather": "Jaké je počasí v Tokiu?"
},
"multiTurn": {
"system": "Jste užitečný pomocník.",
"assistantExample": "Rád vám s tím pomohu.",
"userInitial": "Potřebuji pomoct",
"userFollowUp": "Můžete to upřesnit?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+198 -6
View File
@@ -58,7 +58,85 @@
"free": "Gratis",
"skipToContent": "Gå til indhold",
"maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.",
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting..."
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting...",
"accept": "Accepter",
"accountId": "Konto-id",
"alias": "Alias",
"apiKeyId": "API-nøgle-id",
"apiKeyName": "API-nøglenavn",
"apiKeySecret": "API-nøglehemmelighed",
"authorization": "Autorisation",
"content-type": "Indholdstype",
"content-length": "Indholdslængde",
"cookie": "Cookie",
"file": "Fil",
"host": "vært",
"id": "ID",
"import": "Importer",
"limit": "Grænse",
"offset": "Offset",
"open": "Åbn",
"origin": "Oprindelse",
"promptTokens": "Spørg tokens",
"completionTokens": "Fuldførelsestokens",
"totalTokens": "Samlede tokens",
"rawModel": "Rå model",
"scope": "Omfang",
"skill": "Færdighed",
"sortBy": "Sorter efter",
"sortOrder": "Sorteringsrækkefølge",
"tab": "Tab",
"text": "Tekst",
"textarea": "Tekstområde",
"tool": "Værktøj",
"toolId": "Værktøjs-id",
"web": "Web",
"whereUsed": "Hvor brugt",
"whitelist": "Hvidliste",
"blacklist": "Sortliste",
"resolve": "Løs",
"force": "Kraft",
"base64url": "Base64 URL",
"hex": "Hex",
"range": "Rækkevidde",
"component": "Komponent",
"redirect_uri": "Omdiriger URI",
"idempotency-key": "Idempotens nøgle",
"error_description": "Fejlbeskrivelse",
"code": "Kode",
"compatible": "Kompatibel",
"chat-completions": "Chatafslutninger",
"oauth": "OAuth",
"auth_token": "Auth Token",
"crypto": "Krypto",
"hours": "Timer",
"selfsigned": "Selvsigneret",
"proxy_id": "Proxy ID",
"proxyId": "Proxy ID",
"connectionId": "Forbindelses-id",
"resolveConnectionId": "Løs forbindelses-id",
"resolve_connection_id": "Løs forbindelses-id",
"scope_id": "Omfang ID",
"scopeId": "Omfang ID",
"jwtSecret": "JWT-hemmelighed",
"keytar": "Keytar",
"better-sqlite3": "bedre-sqlite3",
"undici": "undici",
"builder-id": "Bygherre-id",
"musicDesc": "Musikbeskrivelse",
"musicGeneration": "Musik Generation",
"idc": "IDC",
"cloud-status-changed": "Cloud-status ændret",
"where_used": "Hvor brugt",
"windowMs": "Vindue (ms)",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Værktøjstilladelsesliste",
"TOOL_DENYLIST": "Værktøj Denylist",
"Failed to save pricing": "Kunne ikke gemme prisen",
"Failed to reset pricing": "Failed to reset pricing",
"apikey": "API nøgle",
"http": "HTTP"
},
"sidebar": {
"home": "Hjem",
@@ -107,7 +185,9 @@
"agents": "Agenter",
"cliToolsShort": "Værktøjer",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Temaer",
@@ -179,7 +259,11 @@
"requestsShort": "{count} req",
"providerModelsTitle": "{provider} - Modeller",
"copiedModel": "Kopieret: {model}",
"aliasLabel": "alias"
"aliasLabel": "alias",
"updateNow": "Opdater nu",
"updating": "Opdaterer...",
"updateAvailableDesc": "En ny version er tilgængelig. Klik for at opdatere.",
"updateStarted": "Opdatering startet..."
},
"analytics": {
"title": "Analytics",
@@ -535,6 +619,9 @@
"title": "Tilføj Model Config",
"desc": "Tilføj følgende konfiguration til dit modelarray:"
}
},
"notes": {
"0": "Continue bruger JSON-konfigurationsfil."
}
},
"opencode": {
@@ -553,6 +640,10 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "OpenCode kræver API-nøglekonfiguration.",
"1": "Indstil basis-URL'en til dit OmniRoute-slutpunkt."
}
},
"kiro": {
@@ -571,6 +662,9 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "Kiro kræver en Amazon-konto."
}
}
},
@@ -1314,6 +1408,10 @@
"chatCompletions": "Chatafslutninger",
"importingModels": "Importerer...",
"importFromModels": "Importer fra /models",
"clearAllModels": "Ryd alle modeller",
"clearAllModelsConfirm": "Er du sikker på, at du vil fjerne alle modeller for denne udbyder? Dette kan ikke fortrydes.",
"clearAllModelsSuccess": "Alle modeller ryddet",
"clearAllModelsFailed": "Kunne ikke rydde modeller",
"addConnectionToImport": "Tilføj en forbindelse for at aktivere import.",
"noModelsConfigured": "Ingen modeller konfigureret",
"connectionCount": "{count} forbindelse(r)",
@@ -1477,7 +1575,16 @@
"compatUpstreamHeaderValue": "Value",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamRemoveRow": "Remove row"
"compatUpstreamRemoveRow": "Remove row",
"autoSync": "Auto-synkronisering",
"autoSyncTooltip": "Opdater modellisten automatisk hver 24. time (kan konfigureres via MODEL_SYNC_INTERVAL_HOURS)",
"autoSyncEnabled": "Automatisk synkronisering aktiveret - modellerne opdateres med jævne mellemrum",
"autoSyncDisabled": "Automatisk synkronisering deaktiveret",
"autoSyncToggleFailed": "Automatisk synkronisering kunne ikke slås til eller fra",
"clearAllModels": "Ryd alle modeller",
"clearAllModelsConfirm": "Er du sikker på, at du vil fjerne alle modeller for denne udbyder? Dette kan ikke fortrydes.",
"clearAllModelsSuccess": "Alle modeller ryddet",
"clearAllModelsFailed": "Det lykkedes ikke at rydde modeller"
},
"settings": {
"title": "Indstillinger",
@@ -2304,7 +2411,15 @@
"restartServerWithNewPassword": "Genstart serveren - den vil bruge den nye adgangskode",
"backToLogin": "Tilbage til Login",
"forgotPassword": "Glemt adgangskode?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"Authorization": "Autorisation",
"Content-Disposition": "Indhold-Disposition",
"waitingForAuthorization": "Venter på godkendelse...",
"waitingForGoogleAuthorization": "Venter på Google-godkendelse...",
"waitingForOpenAIAuthorization": "Venter på OpenAI-godkendelse...",
"waitingForAntigravityAuthorization": "Venter på antigravity-autorisation...",
"waitingForIFlowAuthorization": "Venter på iFlow-godkendelse...",
"exchangingCodeForTokens": "Udveksler kode til tokens..."
},
"landing": {
"brandName": "OmniRoute",
@@ -2511,7 +2626,9 @@
"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."
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"endpointSpeechNote": "Tekst-til-tale generation (ElevenLabs, OpenAI TTS).",
"endpointEmbeddingsNote": "Generering af tekstindlejring (OpenAI, Cohere, Voyage)."
},
"legal": {
"privacyPolicy": "Privatlivspolitik",
@@ -2689,5 +2806,80 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"templateNames": {
"simple-chat": "Simpel chat",
"streaming": "Streaming",
"system-prompt": "Systemprompt",
"thinking": "Tænker",
"tool-calling": "Værktøjsopkald",
"multi-turn": "Multivending"
},
"templateDescriptions": {
"simple-chat": "Grundlæggende chatskabelon med systembesked",
"streaming": "Skabelon til streaming svar",
"system-prompt": "Skabelon med brugerdefineret systemprompt",
"thinking": "Skabelon med ræsonnement/tænkebudget",
"tool-calling": "Skabelon til værktøj/funktionskald",
"multi-turn": "Skabelon til samtaler med flere sving"
},
"templatePayloads": {
"simpleChat": {
"system": "Du er en hjælpsom AI-assistent.",
"userGreeting": "Hej! Hvordan kan jeg hjælpe dig i dag?"
},
"streaming": {
"prompt": "Skriv en historie om"
},
"systemPrompt": {
"question": "Hvad er meningen med livet?",
"systemInstruction": "Giv et tankevækkende, filosofisk svar."
},
"thinking": {
"question": "Forklar kvanteberegning"
},
"toolCalling": {
"cityNameDescription": "Navnet på byen for at få vejr til",
"toolDescription": "Få det aktuelle vejr for en placering",
"userWeather": "Hvordan er vejret i Tokyo?"
},
"multiTurn": {
"system": "Du er en hjælpsom assistent.",
"assistantExample": "Det hjælper jeg dig gerne med.",
"userInitial": "Jeg har brug for hjælp til",
"userFollowUp": "Kan du uddybe det?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+69 -2
View File
@@ -185,7 +185,9 @@
"agents": "Agenten",
"cliToolsShort": "Werkzeuge",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themen",
@@ -1406,6 +1408,10 @@
"chatCompletions": "Chat-Abschlüsse",
"importingModels": "Importieren...",
"importFromModels": "Import aus /models",
"clearAllModels": "Alle Modelle löschen",
"clearAllModelsConfirm": "Sind Sie sicher, dass Sie alle Modelle für diesen Anbieter entfernen möchten? Dies kann nicht rückgängig gemacht werden.",
"clearAllModelsSuccess": "Alle Modelle gelöscht",
"clearAllModelsFailed": "Modelle konnten nicht gelöscht werden",
"addConnectionToImport": "Fügen Sie eine Verbindung hinzu, um den Import zu ermöglichen.",
"noModelsConfigured": "Keine Modelle konfiguriert",
"connectionCount": "{count} Verbindung(en)",
@@ -2411,7 +2417,9 @@
"waitingForOpenAIAuthorization": "Warte auf OpenAI-Autorisierung...",
"waitingForAntigravityAuthorization": "Warte auf Antigravity-Autorisierung...",
"waitingForIFlowAuthorization": "Warte auf iFlow-Autorisierung...",
"exchangingCodeForTokens": "Tausche Code gegen Token aus..."
"exchangingCodeForTokens": "Tausche Code gegen Token aus...",
"Authorization": "Autorisierung",
"Content-Disposition": "Inhaltliche Disposition"
},
"landing": {
"brandName": "OmniRoute",
@@ -2814,5 +2822,64 @@
"thinking": "Vorlage mit Denk-/Argumentationsbudget",
"tool-calling": "Vorlage für Werkzeug-/Funktionsaufrufe",
"multi-turn": "Vorlage für mehrstufige Konversationen"
},
"templatePayloads": {
"simpleChat": {
"system": "Sie sind ein hilfreicher KI-Assistent.",
"userGreeting": "Hallo! Wie kann ich Ihnen heute helfen?"
},
"streaming": {
"prompt": "Schreiben Sie eine Geschichte darüber"
},
"systemPrompt": {
"question": "Was ist der Sinn des Lebens?",
"systemInstruction": "Geben Sie eine nachdenkliche, philosophische Antwort."
},
"thinking": {
"question": "Erklären Sie Quantencomputing"
},
"toolCalling": {
"cityNameDescription": "Der Name der Stadt, für die das Wetter ermittelt werden soll",
"toolDescription": "Erhalten Sie das aktuelle Wetter für einen Standort",
"userWeather": "Wie ist das Wetter in Tokio?"
},
"multiTurn": {
"system": "Sie sind ein hilfreicher Assistent.",
"assistantExample": "Gerne helfe ich Ihnen dabei.",
"userInitial": "Ich brauche Hilfe dabei",
"userFollowUp": "Können Sie das näher erläutern?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+41 -1
View File
@@ -185,7 +185,9 @@
"themeViolet": "Violet",
"themeOrange": "Orange",
"themeCyan": "Cyan",
"cliToolsShort": "Tools"
"cliToolsShort": "Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themes",
@@ -1635,6 +1637,12 @@
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
"tokenRefreshed": "Token refreshed successfully",
"tokenRefreshFailed": "Token refresh failed",
"applyCodexAuthLocal": "Apply auth",
"exportCodexAuthFile": "Export auth",
"codexAuthAppliedLocal": "Codex auth.json applied locally",
"codexAuthApplyFailed": "Failed to apply Codex auth.json locally",
"codexAuthExported": "Codex auth.json exported",
"codexAuthExportFailed": "Failed to export Codex auth.json",
"advancedSettings": "Advanced Settings",
"chatPathLabel": "Chat Endpoint Path",
"chatPathPlaceholder": "/chat/completions",
@@ -2843,5 +2851,37 @@
"userInitial": "I need help with",
"userFollowUp": "Can you elaborate on that?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"memoryEntriesSub": "In-memory LRU",
"dbEntries": "DB Entries",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHits": "Cache Hits",
"cacheHitsSub": "of {total} total",
"tokensSaved": "Tokens Saved",
"tokensSavedSub": "Estimated from hits",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behavior": "Cache Behavior",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"idempotency": "Idempotency Layer",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running."
}
}
+78 -3
View File
@@ -128,7 +128,15 @@
"idc": "idc",
"cloud-status-changed": "cloud-status-changed",
"where_used": "where_used",
"windowMs": "windowMs"
"windowMs": "windowMs",
"social-github": "GitHub",
"social-google": "google",
"TOOL_ALLOWLIST": "Lista de herramientas permitidas",
"TOOL_DENYLIST": "Lista de denegaciones de herramientas",
"Failed to save pricing": "No se pudo guardar el precio",
"Failed to reset pricing": "No se pudo restablecer el precio",
"apikey": "Clave API",
"http": "HTTP"
},
"sidebar": {
"home": "Inicio",
@@ -177,7 +185,9 @@
"agents": "Agentes",
"cliToolsShort": "Herramientas",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Temas",
@@ -1398,6 +1408,10 @@
"chatCompletions": "Finalizaciones de chat",
"importingModels": "Importando...",
"importFromModels": "Importar desde /modelos",
"clearAllModels": "Borrar todos los modelos",
"clearAllModelsConfirm": "¿Estás seguro de que quieres eliminar todos los modelos de este proveedor? Esta acción no se puede deshacer.",
"clearAllModelsSuccess": "Todos los modelos eliminados",
"clearAllModelsFailed": "Error al eliminar los modelos",
"addConnectionToImport": "Agregue una conexión para permitir la importación.",
"noModelsConfigured": "No hay modelos configurados",
"connectionCount": "{count} conexión(es)",
@@ -2403,7 +2417,9 @@
"waitingForOpenAIAuthorization": "Waiting for OpenAI authorization...",
"waitingForAntigravityAuthorization": "Waiting for Antigravity authorization...",
"waitingForIFlowAuthorization": "Waiting for iFlow authorization...",
"exchangingCodeForTokens": "Exchanging code for tokens..."
"exchangingCodeForTokens": "Exchanging code for tokens...",
"Authorization": "Autorización",
"Content-Disposition": "Disposición de contenido"
},
"landing": {
"brandName": "OmniRuta",
@@ -2806,5 +2822,64 @@
"thinking": "Thinking template",
"tool-calling": "Tool calling template",
"multi-turn": "Multi-turn template"
},
"templatePayloads": {
"simpleChat": {
"system": "Eres un útil asistente de IA.",
"userGreeting": "¡Hola! ¿Cómo puedo ayudarte hoy?"
},
"streaming": {
"prompt": "Escribe una historia sobre"
},
"systemPrompt": {
"question": "¿Cuál es el significado de la vida?",
"systemInstruction": "Proporcione una respuesta reflexiva y filosófica."
},
"thinking": {
"question": "Explicar la computación cuántica"
},
"toolCalling": {
"cityNameDescription": "El nombre de la ciudad para obtener el clima.",
"toolDescription": "Obtener el clima actual para una ubicación",
"userWeather": "¿Cuál es el clima en Tokio?"
},
"multiTurn": {
"system": "Eres un asistente útil.",
"assistantExample": "Estaré encantado de ayudarte con eso.",
"userInitial": "necesito ayuda con",
"userFollowUp": "¿Puedes dar más detalles sobre eso?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+198 -6
View File
@@ -58,7 +58,85 @@
"free": "Ilmainen",
"skipToContent": "Siirry sisältöön",
"maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.",
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting..."
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting...",
"accept": "Hyväksy",
"accountId": "Tilin tunnus",
"alias": "Alias",
"apiKeyId": "API-avaimen tunnus",
"apiKeyName": "API-avaimen nimi",
"apiKeySecret": "API-avaimen salaisuus",
"authorization": "Valtuutus",
"content-type": "Sisältötyyppi",
"content-length": "Sisällön pituus",
"cookie": "Eväste",
"file": "Tiedosto",
"host": "Isäntä",
"id": "ID",
"import": "Tuo",
"limit": "Raja",
"offset": "Offset",
"open": "Avaa",
"origin": "Alkuperä",
"promptTokens": "Kehotusmerkit",
"completionTokens": "Päättymismerkit",
"totalTokens": "Tokeneja yhteensä",
"rawModel": "Raaka malli",
"scope": "Laajuus",
"skill": "Taito",
"sortBy": "Lajitteluperuste",
"sortOrder": "Lajittelujärjestys",
"tab": "Tab",
"text": "Teksti",
"textarea": "Textarea",
"tool": "Työkalu",
"toolId": "Työkalun tunnus",
"web": "Web",
"whereUsed": "Missä käytetty",
"whitelist": "Valkoinen lista",
"blacklist": "Musta lista",
"resolve": "Ratkaise",
"force": "Voimaa",
"base64url": "Base64 URL",
"hex": "Hex",
"range": "Alue",
"component": "Komponentti",
"redirect_uri": "Uudelleenohjaus URI",
"idempotency-key": "Idempotenssiavain",
"error_description": "Virheen kuvaus",
"code": "Koodi",
"compatible": "Yhteensopiva",
"chat-completions": "Chatin loppuun saattaminen",
"oauth": "OAuth",
"auth_token": "Auth Token",
"crypto": "Krypto",
"hours": "Tuntia",
"selfsigned": "Itse allekirjoitettu",
"proxy_id": "Välityspalvelimen tunnus",
"proxyId": "Välityspalvelimen tunnus",
"connectionId": "Yhteystunnus",
"resolveConnectionId": "Ratkaise yhteystunnus",
"resolve_connection_id": "Ratkaise yhteystunnus",
"scope_id": "Laajuustunnus",
"scopeId": "Laajuustunnus",
"jwtSecret": "JWT Secret",
"keytar": "Keytar",
"better-sqlite3": "parempi-sqlite3",
"undici": "undici",
"builder-id": "Rakentajan tunnus",
"musicDesc": "Musiikki Kuvaus",
"musicGeneration": "Musiikin sukupolvi",
"idc": "IDC",
"cloud-status-changed": "Pilven tila muuttunut",
"where_used": "Missä käytetty",
"windowMs": "Ikkuna (ms)",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Työkalujen sallittu luettelo",
"TOOL_DENYLIST": "Tool Denylist",
"Failed to save pricing": "Hinnoittelun tallentaminen epäonnistui",
"Failed to reset pricing": "Hinnoittelun nollaaminen epäonnistui",
"apikey": "API-avain",
"http": "HTTP"
},
"sidebar": {
"home": "Kotiin",
@@ -107,7 +185,9 @@
"agents": "Agentit",
"cliToolsShort": "Työkalut",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Teemat",
@@ -179,7 +259,11 @@
"requestsShort": "{count} vaatimus",
"providerModelsTitle": "{provider} - Mallit",
"copiedModel": "Kopioitu: {model}",
"aliasLabel": "alias"
"aliasLabel": "alias",
"updateNow": "Päivitä nyt",
"updating": "Päivitetään...",
"updateAvailableDesc": "Uusi versio on saatavilla. Päivitä napsauttamalla.",
"updateStarted": "Päivitys aloitettu..."
},
"analytics": {
"title": "Analytics",
@@ -535,6 +619,9 @@
"title": "Lisää mallin kokoonpano",
"desc": "Lisää seuraavat kokoonpanot mallien matriisiisi:"
}
},
"notes": {
"0": "Jatka käyttää JSON-määritystiedostoa."
}
},
"opencode": {
@@ -553,6 +640,10 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "OpenCode vaatii API-avaimen määrityksen.",
"1": "Aseta perus-URL-osoite OmniRoute-päätepisteellesi."
}
},
"kiro": {
@@ -571,6 +662,9 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "Kiro vaatii Amazon-tilin."
}
}
},
@@ -1314,6 +1408,10 @@
"chatCompletions": "Chatin loppuun saattaminen",
"importingModels": "Tuodaan...",
"importFromModels": "Tuo / mallit",
"clearAllModels": "Tyhjennä kaikki mallit",
"clearAllModelsConfirm": "Haluatko varmasti poistaa kaikki tämän palveluntarjoajan mallit? Tätä ei voi kumota.",
"clearAllModelsSuccess": "Kaikki mallit tyhjennetty",
"clearAllModelsFailed": "Mallien tyhjentäminen epäonnistui",
"addConnectionToImport": "Lisää yhteys ottaaksesi tuonnin käyttöön.",
"noModelsConfigured": "Ei malleja määritetty",
"connectionCount": "{count} yhteyttä",
@@ -1477,7 +1575,16 @@
"compatUpstreamHeaderValue": "Value",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamRemoveRow": "Remove row"
"compatUpstreamRemoveRow": "Remove row",
"autoSync": "Automaattinen synkronointi",
"autoSyncTooltip": "Päivitä malliluettelo automaattisesti 24 tunnin välein (konfiguroitavissa kohdassa MODEL_SYNC_INTERVAL_HOURS)",
"autoSyncEnabled": "Automaattinen synkronointi käytössä mallit päivittyvät säännöllisesti",
"autoSyncDisabled": "Automaattinen synkronointi poistettu käytöstä",
"autoSyncToggleFailed": "Automaattisen synkronoinnin vaihtaminen epäonnistui",
"clearAllModels": "Tyhjennä kaikki mallit",
"clearAllModelsConfirm": "Haluatko varmasti poistaa kaikki tämän palveluntarjoajan mallit? Tätä ei voi kumota.",
"clearAllModelsSuccess": "Kaikki mallit tyhjennetty",
"clearAllModelsFailed": "Mallien tyhjennys epäonnistui"
},
"settings": {
"title": "Asetukset",
@@ -2304,7 +2411,15 @@
"restartServerWithNewPassword": "Käynnistä palvelin uudelleen - se käyttää uutta salasanaa",
"backToLogin": "Takaisin kirjautumiseen",
"forgotPassword": "Unohditko salasanan?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"Authorization": "Valtuutus",
"Content-Disposition": "Sisältö-sijoittelu",
"waitingForAuthorization": "Odotetaan valtuutusta...",
"waitingForGoogleAuthorization": "Odotetaan Googlen valtuutusta...",
"waitingForOpenAIAuthorization": "Odotetaan OpenAI-valtuutusta...",
"waitingForAntigravityAuthorization": "Odotetaan antigravitaatiovaltuutusta...",
"waitingForIFlowAuthorization": "Odotetaan iFlow-valtuutusta...",
"exchangingCodeForTokens": "Vaihdetaan koodia tokeneihin..."
},
"landing": {
"brandName": "OmniRoute",
@@ -2511,7 +2626,9 @@
"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."
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"endpointSpeechNote": "Tekstistä puheeksi luominen (ElevenLabs, OpenAI TTS).",
"endpointEmbeddingsNote": "Tekstin upottaminen (OpenAI, Cohere, Voyage)."
},
"legal": {
"privacyPolicy": "Tietosuojakäytäntö",
@@ -2689,5 +2806,80 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"templateNames": {
"simple-chat": "Yksinkertainen chat",
"streaming": "Suoratoisto",
"system-prompt": "Järjestelmäkehote",
"thinking": "Ajatteleminen",
"tool-calling": "Työkalun kutsuminen",
"multi-turn": "Monikierros"
},
"templateDescriptions": {
"simple-chat": "Peruskeskustelumalli järjestelmäviestillä",
"streaming": "Malli vastausten suoratoistoon",
"system-prompt": "Malli mukautetulla järjestelmäkehotteella",
"thinking": "Malli perustelulla/ajattelulla",
"tool-calling": "Malli työkalun/toiminnon kutsumiseen",
"multi-turn": "Malli usean käännöksen keskusteluille"
},
"templatePayloads": {
"simpleChat": {
"system": "Olet avulias AI-avustaja.",
"userGreeting": "Hei! Kuinka voin auttaa sinua tänään?"
},
"streaming": {
"prompt": "Kirjoita tarina aiheesta"
},
"systemPrompt": {
"question": "Mikä on elämän tarkoitus?",
"systemInstruction": "Anna harkittu, filosofinen vastaus."
},
"thinking": {
"question": "Selitä kvanttilaskenta"
},
"toolCalling": {
"cityNameDescription": "Kaupungin nimi, jolle sää haetaan",
"toolDescription": "Hanki sijainnin nykyinen sää",
"userWeather": "Millainen sää on Tokiossa?"
},
"multiTurn": {
"system": "Olet avulias avustaja.",
"assistantExample": "Autan sinua siinä mielelläni.",
"userInitial": "Tarvitsen apua",
"userFollowUp": "Voitko tarkentaa sitä?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+78 -3
View File
@@ -128,7 +128,15 @@
"idc": "idc",
"cloud-status-changed": "cloud-status-changed",
"where_used": "where_used",
"windowMs": "windowMs"
"windowMs": "windowMs",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Liste autorisée des outils",
"TOOL_DENYLIST": "Liste de refus d'outils",
"Failed to save pricing": "Échec de l'enregistrement des prix",
"Failed to reset pricing": "Échec de la réinitialisation des prix",
"apikey": "Clé API",
"http": "HTTP"
},
"sidebar": {
"home": "Accueil",
@@ -177,7 +185,9 @@
"agents": "Agents",
"cliToolsShort": "Outils",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Thèmes",
@@ -1398,6 +1408,10 @@
"chatCompletions": "Achèvements des discussions",
"importingModels": "Importation...",
"importFromModels": "Importer depuis /models",
"clearAllModels": "Supprimer tous les modèles",
"clearAllModelsConfirm": "Êtes-vous sûr de vouloir supprimer tous les modèles de ce fournisseur ? Cette action est irréversible.",
"clearAllModelsSuccess": "Tous les modèles supprimés",
"clearAllModelsFailed": "Échec de la suppression des modèles",
"addConnectionToImport": "Ajoutez une connexion pour activer l'importation.",
"noModelsConfigured": "Aucun modèle configuré",
"connectionCount": "{count} connexion(s)",
@@ -2403,7 +2417,9 @@
"waitingForOpenAIAuthorization": "Waiting for OpenAI authorization...",
"waitingForAntigravityAuthorization": "Waiting for Antigravity authorization...",
"waitingForIFlowAuthorization": "Waiting for iFlow authorization...",
"exchangingCodeForTokens": "Exchanging code for tokens..."
"exchangingCodeForTokens": "Exchanging code for tokens...",
"Authorization": "Autorisation",
"Content-Disposition": "Disposition du contenu"
},
"landing": {
"brandName": "OmniRoute",
@@ -2806,5 +2822,64 @@
"thinking": "Thinking template",
"tool-calling": "Tool calling template",
"multi-turn": "Multi-turn template"
},
"templatePayloads": {
"simpleChat": {
"system": "Vous êtes un assistant IA utile.",
"userGreeting": "Bonjour ! Comment puis-je vous aider aujourd'hui ?"
},
"streaming": {
"prompt": "Écrire une histoire sur"
},
"systemPrompt": {
"question": "Quel est le sens de la vie ?",
"systemInstruction": "Fournissez une réponse réfléchie et philosophique."
},
"thinking": {
"question": "Expliquer l'informatique quantique"
},
"toolCalling": {
"cityNameDescription": "Le nom de la ville pour laquelle obtenir la météo",
"toolDescription": "Obtenir la météo actuelle pour un emplacement",
"userWeather": "Quel temps fait-il à Tokyo ?"
},
"multiTurn": {
"system": "Vous êtes un assistant utile.",
"assistantExample": "Je serais heureux de vous aider avec cela.",
"userInitial": "J'ai besoin d'aide pour",
"userFollowUp": "Pouvez-vous développer cela ?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+198 -6
View File
@@ -58,7 +58,85 @@
"free": "חינם",
"skipToContent": "דלג לתוכן",
"maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.",
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting..."
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting...",
"accept": "קבל",
"accountId": "מזהה חשבון",
"alias": "כינוי",
"apiKeyId": "מזהה מפתח API",
"apiKeyName": "שם מפתח API",
"apiKeySecret": "סוד מפתח API",
"authorization": "הרשאה",
"content-type": "סוג תוכן",
"content-length": "אורך תוכן",
"cookie": "קוקי",
"file": "קובץ",
"host": "מארח",
"id": "תעודה מזהה",
"import": "ייבוא",
"limit": "הגבלה",
"offset": "קיזוז",
"open": "פתוח",
"origin": "מוצא",
"promptTokens": "אסימוני הנחיה",
"completionTokens": "אסימוני השלמה",
"totalTokens": "סך הכל אסימונים",
"rawModel": "דגם גלם",
"scope": "היקף",
"skill": "מיומנות",
"sortBy": "מיין לפי",
"sortOrder": "סדר מיון",
"tab": "כרטיסייה",
"text": "טקסט",
"textarea": "Textarea",
"tool": "כלי",
"toolId": "מזהה כלי",
"web": "אינטרנט",
"whereUsed": "היכן בשימוש",
"whitelist": "רשימת הלבנים",
"blacklist": "רשימה שחורה",
"resolve": "פתרון",
"force": "כוח",
"base64url": "כתובת אתר של Base64",
"hex": "משושה",
"range": "טווח",
"component": "רכיב",
"redirect_uri": "כתובת URL להפניה מחדש",
"idempotency-key": "מפתח אידמפוטנציה",
"error_description": "תיאור שגיאה",
"code": "קוד",
"compatible": "תואם",
"chat-completions": "השלמת צ'אט",
"oauth": "OAuth",
"auth_token": "Auth Token",
"crypto": "קריפטו",
"hours": "שעות",
"selfsigned": "חתום בעצמו",
"proxy_id": "מזהה פרוקסי",
"proxyId": "מזהה פרוקסי",
"connectionId": "מזהה חיבור",
"resolveConnectionId": "פתרון מזהה חיבור",
"resolve_connection_id": "פתרון מזהה חיבור",
"scope_id": "מזהה היקף",
"scopeId": "מזהה היקף",
"jwtSecret": "סוד JWT",
"keytar": "Keytar",
"better-sqlite3": "better-sqlite3",
"undici": "undici",
"builder-id": "מזהה בונה",
"musicDesc": "תיאור מוזיקה",
"musicGeneration": "דור המוזיקה",
"idc": "הבינתחומי",
"cloud-status-changed": "סטטוס הענן השתנה",
"where_used": "היכן בשימוש",
"windowMs": "חלון (ms)",
"social-github": "GitHub",
"social-google": "גוגל",
"TOOL_ALLOWLIST": "רשימת ההיתרים של הכלים",
"TOOL_DENYLIST": "רשימת הכלים",
"Failed to save pricing": "חיסכון במחיר נכשל",
"Failed to reset pricing": "איפוס התמחור נכשל",
"apikey": "מפתח API",
"http": "HTTP"
},
"sidebar": {
"home": "בית",
@@ -107,7 +185,9 @@
"agents": "סוכנים",
"cliToolsShort": "כלים",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themes",
@@ -179,7 +259,11 @@
"requestsShort": "{count} בקשות",
"providerModelsTitle": "{provider} - דגמים",
"copiedModel": "הועתק: {model}",
"aliasLabel": "כינוי"
"aliasLabel": "כינוי",
"updateNow": "עדכן עכשיו",
"updating": "מעדכן...",
"updateAvailableDesc": "גרסה חדשה זמינה. לחץ לעדכון.",
"updateStarted": "העדכון התחיל..."
},
"analytics": {
"title": "אנליטיקס",
@@ -535,6 +619,9 @@
"title": "הוסף דגם Config",
"desc": "הוסף את התצורה הבאה למערך הדגמים שלך:"
}
},
"notes": {
"0": "המשך משתמש בקובץ התצורה של JSON."
}
},
"opencode": {
@@ -553,6 +640,10 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "OpenCode דורש תצורת מפתח API.",
"1": "הגדר את כתובת האתר הבסיסית לנקודת הקצה של OmniRoute שלך."
}
},
"kiro": {
@@ -571,6 +662,9 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "Kiro דורש חשבון אמזון."
}
}
},
@@ -1314,6 +1408,10 @@
"chatCompletions": "השלמת צ'אט",
"importingModels": "מייבא...",
"importFromModels": "ייבוא מ /models",
"clearAllModels": "מחק את כל המודלים",
"clearAllModelsConfirm": "האם אתה בטוח שברצונך להסיר את כל המודלים עבור ספק זה? לא ניתן לבטל פעולה זו.",
"clearAllModelsSuccess": "כל המודלים נמחקו",
"clearAllModelsFailed": "מחיקת המודלים נכשלה",
"addConnectionToImport": "הוסף חיבור כדי לאפשר ייבוא.",
"noModelsConfigured": "לא הוגדרו דגמים",
"connectionCount": "{count} חיבור(ים)",
@@ -1477,7 +1575,16 @@
"compatUpstreamHeaderValue": "Value",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamRemoveRow": "Remove row"
"compatUpstreamRemoveRow": "Remove row",
"autoSync": "סנכרון אוטומטי",
"autoSyncTooltip": "רענן אוטומטית את רשימת הדגמים כל 24 שעות (ניתן להגדרה באמצעות MODEL_SYNC_INTERVAL_HOURS)",
"autoSyncEnabled": "סנכרון אוטומטי מופעל - הדגמים יתרעננו מעת לעת",
"autoSyncDisabled": "הסנכרון האוטומטי מושבת",
"autoSyncToggleFailed": "החלפת הסנכרון האוטומטי נכשלה",
"clearAllModels": "נקה את כל הדגמים",
"clearAllModelsConfirm": "האם אתה בטוח שברצונך להסיר את כל הדגמים עבור ספק זה? לא ניתן לבטל זאת.",
"clearAllModelsSuccess": "כל הדגמים נוקו",
"clearAllModelsFailed": "ניקוי הדגמים נכשל"
},
"settings": {
"title": "הגדרות",
@@ -2304,7 +2411,15 @@
"restartServerWithNewPassword": "הפעל מחדש את השרת - הוא ישתמש בסיסמה החדשה",
"backToLogin": "חזרה לכניסה",
"forgotPassword": "שכחת סיסמה?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"Authorization": "הרשאה",
"Content-Disposition": "תוכן-נטייה",
"waitingForAuthorization": "ממתין לאישור...",
"waitingForGoogleAuthorization": "ממתין לאישור Google...",
"waitingForOpenAIAuthorization": "ממתין להרשאת OpenAI...",
"waitingForAntigravityAuthorization": "ממתין לאישור נגד כבידה...",
"waitingForIFlowAuthorization": "ממתין להרשאת iFlow...",
"exchangingCodeForTokens": "מחליף קוד לאסימונים..."
},
"landing": {
"brandName": "OmniRoute",
@@ -2511,7 +2626,9 @@
"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."
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"endpointSpeechNote": "יצירת טקסט לדיבור (ElevenLabs, OpenAI TTS).",
"endpointEmbeddingsNote": "יצירת הטבעת טקסט (OpenAI, Cohere, Voyage)."
},
"legal": {
"privacyPolicy": "מדיניות פרטיות",
@@ -2689,5 +2806,80 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"templateNames": {
"simple-chat": "צ'אט פשוט",
"streaming": "סטרימינג",
"system-prompt": "הנחית מערכת",
"thinking": "חושבים",
"tool-calling": "כלי שיחות",
"multi-turn": "רב סיבובים"
},
"templateDescriptions": {
"simple-chat": "תבנית צ'אט בסיסית עם הודעת מערכת",
"streaming": "תבנית להזרמת תגובות",
"system-prompt": "תבנית עם הודעת מערכת מותאמת אישית",
"thinking": "תבנית עם היגיון/תקציב חשיבה",
"tool-calling": "תבנית לקריאת כלי/פונקציה",
"multi-turn": "תבנית לשיחות מרובי פניות"
},
"templatePayloads": {
"simpleChat": {
"system": "אתה עוזר AI מועיל.",
"userGreeting": "שלום! איך אני יכול לעזור לך היום?"
},
"streaming": {
"prompt": "כתבו סיפור על"
},
"systemPrompt": {
"question": "מהי משמעות החיים?",
"systemInstruction": "ספק תשובה מהורהרת, פילוסופית."
},
"thinking": {
"question": "הסבר מחשוב קוונטי"
},
"toolCalling": {
"cityNameDescription": "שם העיר שאפשר לקבל עליה מזג אוויר",
"toolDescription": "קבל מזג אוויר עדכני עבור מיקום",
"userWeather": "מה מזג האוויר בטוקיו?"
},
"multiTurn": {
"system": "אתה עוזר מועיל.",
"assistantExample": "אני אשמח לעזור לך עם זה.",
"userInitial": "אני צריך עזרה עם",
"userFollowUp": "אתה יכול לפרט על זה?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+35 -1
View File
@@ -107,7 +107,9 @@
"agents": "एजेंट",
"cliToolsShort": "उपकरण",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themes",
@@ -2689,5 +2691,37 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+198 -6
View File
@@ -58,7 +58,85 @@
"free": "Ingyenes",
"skipToContent": "Ugrás a tartalomhoz",
"maintenanceServerIssues": "Server is experiencing issues. Some features may be unavailable.",
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting..."
"maintenanceServerUnreachable": "Server is unreachable. Reconnecting...",
"accept": "Elfogadás",
"accountId": "Számlaazonosító",
"alias": "Alias",
"apiKeyId": "API kulcs azonosítója",
"apiKeyName": "API kulcs neve",
"apiKeySecret": "API kulcs titka",
"authorization": "Engedélyezés",
"content-type": "Tartalom típusa",
"content-length": "Tartalom hossza",
"cookie": "Cookie",
"file": "Fájl",
"host": "Házigazda",
"id": "ID",
"import": "Importálás",
"limit": "Limit",
"offset": "Offset",
"open": "Nyissa meg",
"origin": "Eredet",
"promptTokens": "Prompt Tokenek",
"completionTokens": "Befejezési tokenek",
"totalTokens": "Összes token",
"rawModel": "Nyers modell",
"scope": "Hatály",
"skill": "Ügyesség",
"sortBy": "Rendezés alapja",
"sortOrder": "Rendezési sorrend",
"tab": "Tab",
"text": "Szöveg",
"textarea": "Textarea",
"tool": "Eszköz",
"toolId": "Szerszámazonosító",
"web": "Web",
"whereUsed": "Hol használták",
"whitelist": "Fehérlista",
"blacklist": "Feketelista",
"resolve": "Oldja meg",
"force": "Kényszer",
"base64url": "Base64 URL",
"hex": "Hex",
"range": "Tartomány",
"component": "Összetevő",
"redirect_uri": "Átirányítási URI",
"idempotency-key": "Idempotencia kulcs",
"error_description": "Hiba leírása",
"code": "kód",
"compatible": "Kompatibilis",
"chat-completions": "Csevegés befejezése",
"oauth": "OAuth",
"auth_token": "Auth Token",
"crypto": "Crypto",
"hours": "Órák",
"selfsigned": "Önaláírt",
"proxy_id": "Proxy azonosítója",
"proxyId": "Proxy azonosítója",
"connectionId": "Csatlakozási azonosító",
"resolveConnectionId": "A kapcsolatazonosító feloldása",
"resolve_connection_id": "A kapcsolatazonosító feloldása",
"scope_id": "Hatókör azonosítója",
"scopeId": "Hatókör azonosítója",
"jwtSecret": "JWT Secret",
"keytar": "Keytar",
"better-sqlite3": "jobb-sqlite3",
"undici": "undici",
"builder-id": "Építőazonosító",
"musicDesc": "Zene Leírás",
"musicGeneration": "Zenegeneráció",
"idc": "IDC",
"cloud-status-changed": "A felhő állapota megváltozott",
"where_used": "Hol használták",
"windowMs": "Ablak (ms)",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Eszköz engedélyezési lista",
"TOOL_DENYLIST": "Tool Denylist",
"Failed to save pricing": "Nem sikerült menteni az árakat",
"Failed to reset pricing": "Nem sikerült visszaállítani az árakat",
"apikey": "API kulcs",
"http": "HTTP"
},
"sidebar": {
"home": "Otthon",
@@ -107,7 +185,9 @@
"agents": "Ügynökök",
"cliToolsShort": "Eszközök",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Témák",
@@ -179,7 +259,11 @@
"requestsShort": "{count} igény",
"providerModelsTitle": "{provider} - Modellek",
"copiedModel": "Másolva: {model}",
"aliasLabel": "alias"
"aliasLabel": "alias",
"updateNow": "Frissítés most",
"updating": "Frissítés...",
"updateAvailableDesc": "Új verzió érhető el. Kattintson a frissítéshez.",
"updateStarted": "Frissítés elindult..."
},
"analytics": {
"title": "Analytics",
@@ -535,6 +619,9 @@
"title": "Add Model Config",
"desc": "Adja hozzá a következő konfigurációt a modellek tömbjéhez:"
}
},
"notes": {
"0": "A folytatás a JSON konfigurációs fájl használatával."
}
},
"opencode": {
@@ -553,6 +640,10 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "Az OpenCode API-kulcs-konfigurációt igényel.",
"1": "Állítsa be az alap URL-t az OmniRoute végpontra."
}
},
"kiro": {
@@ -571,6 +662,9 @@
"4": {
"title": "Select Model"
}
},
"notes": {
"0": "A Kiro Amazon-fiókot igényel."
}
}
},
@@ -1314,6 +1408,10 @@
"chatCompletions": "Csevegés befejezése",
"importingModels": "Importálás...",
"importFromModels": "Importálás a /models-ből",
"clearAllModels": "Összes modell törlése",
"clearAllModelsConfirm": "Biztosan el szeretné távolítani az összes modellt ehhez a szolgáltatóhoz? Ez a művelet nem vonható vissza.",
"clearAllModelsSuccess": "Összes modell törölve",
"clearAllModelsFailed": "A modellek törlése sikertelen",
"addConnectionToImport": "Adjon hozzá egy kapcsolatot az importálás engedélyezéséhez.",
"noModelsConfigured": "Nincsenek konfigurálva modellek",
"connectionCount": "{count} kapcsolat",
@@ -1477,7 +1575,16 @@
"compatUpstreamHeaderValue": "Value",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamRemoveRow": "Remove row"
"compatUpstreamRemoveRow": "Remove row",
"autoSync": "Automatikus szinkronizálás",
"autoSyncTooltip": "A modelllista automatikus frissítése 24 óránként (konfigurálható: MODEL_SYNC_INTERVAL_HOURS)",
"autoSyncEnabled": "Automatikus szinkronizálás engedélyezve a modellek rendszeresen frissülnek",
"autoSyncDisabled": "Az automatikus szinkronizálás letiltva",
"autoSyncToggleFailed": "Failed to toggle auto-sync",
"clearAllModels": "Minden modell törlése",
"clearAllModelsConfirm": "Biztosan eltávolítja ennek a szolgáltatónak az összes modelljét? Ezt nem lehet visszavonni.",
"clearAllModelsSuccess": "Minden modell törölve",
"clearAllModelsFailed": "Nem sikerült törölni a modelleket"
},
"settings": {
"title": "Beállítások elemre",
@@ -2304,7 +2411,15 @@
"restartServerWithNewPassword": "Indítsa újra a szervert - az új jelszót fogja használni",
"backToLogin": "Vissza a Bejelentkezéshez",
"forgotPassword": "Elfelejtetted a jelszavad?",
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)"
"defaultPasswordHint": "Default password: 123456 (unless INITIAL_PASSWORD was set)",
"Authorization": "Engedélyezés",
"Content-Disposition": "Tartalom-Diszpozíció",
"waitingForAuthorization": "Várakozás az engedélyezésre...",
"waitingForGoogleAuthorization": "Várakozás a Google engedélyére...",
"waitingForOpenAIAuthorization": "Várakozás az OpenAI engedélyezésére...",
"waitingForAntigravityAuthorization": "Várakozás az Antigravitációs engedélyezésre...",
"waitingForIFlowAuthorization": "Várakozás az iFlow engedélyezésére...",
"exchangingCodeForTokens": "Kód cseréje tokenre..."
},
"landing": {
"brandName": "OmniRoute",
@@ -2511,7 +2626,9 @@
"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."
"mgmtLegacyMigrationNote": "Import legacy proxyConfig maps into registry assignments.",
"endpointSpeechNote": "Szövegfelolvasó generálás (ElevenLabs, OpenAI TTS).",
"endpointEmbeddingsNote": "Szövegbeágyazás generálása (OpenAI, Cohere, Voyage)."
},
"legal": {
"privacyPolicy": "Adatvédelmi szabályzat",
@@ -2689,5 +2806,80 @@
"domainPlaceholder": "example.com",
"requestTimedOut": "Request timed out ({seconds}s)",
"networkError": "Network error"
},
"templateNames": {
"simple-chat": "Egyszerű csevegés",
"streaming": "Streaming",
"system-prompt": "Rendszer prompt",
"thinking": "Gondolkodás",
"tool-calling": "Szerszámhívás",
"multi-turn": "Többfordulós"
},
"templateDescriptions": {
"simple-chat": "Alapvető chat-sablon rendszerüzenettel",
"streaming": "Sablon a válaszok streameléséhez",
"system-prompt": "Sablon egyéni rendszerkéréssel",
"thinking": "Sablon érveléssel/gondolkodó költségvetéssel",
"tool-calling": "Sablon az eszköz/funkció hívásához",
"multi-turn": "Sablon többfordulós beszélgetésekhez"
},
"templatePayloads": {
"simpleChat": {
"system": "Segítőkész AI-asszisztens vagy.",
"userGreeting": "Sziasztok! Hogyan segíthetek ma?"
},
"streaming": {
"prompt": "Írj egy történetet róla"
},
"systemPrompt": {
"question": "Mi az élet értelme?",
"systemInstruction": "Adjon megfontolt, filozófiai választ."
},
"thinking": {
"question": "Magyarázza el a kvantumszámítást!"
},
"toolCalling": {
"cityNameDescription": "A város neve, amelyhez időjárást kell kérni",
"toolDescription": "Az adott hely aktuális időjárásának megtekintése",
"userWeather": "Milyen az idő Tokióban?"
},
"multiTurn": {
"system": "Segítőkész asszisztens vagy.",
"assistantExample": "Szívesen segítek ebben.",
"userInitial": "Segítségre van szükségem",
"userFollowUp": "Kifejtenéd ezt bővebben?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+78 -3
View File
@@ -128,7 +128,15 @@
"idc": "idc",
"cloud-status-changed": "cloud-status-changed",
"where_used": "where_used",
"windowMs": "windowMs"
"windowMs": "windowMs",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Daftar Alat yang Diizinkan",
"TOOL_DENYLIST": "Daftar Penolakan Alat",
"Failed to save pricing": "Gagal menyimpan harga",
"Failed to reset pricing": "Gagal menyetel ulang harga",
"apikey": "Kunci API",
"http": "HTTP"
},
"sidebar": {
"home": "Rumah",
@@ -177,7 +185,9 @@
"agents": "Agen",
"cliToolsShort": "Alat",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themes",
@@ -1398,6 +1408,10 @@
"chatCompletions": "Penyelesaian Obrolan",
"importingModels": "Mengimpor...",
"importFromModels": "Impor dari /models",
"clearAllModels": "Hapus Semua Model",
"clearAllModelsConfirm": "Apakah Anda yakin ingin menghapus semua model untuk penyedia ini? Tindakan ini tidak dapat dibatalkan.",
"clearAllModelsSuccess": "Semua model dihapus",
"clearAllModelsFailed": "Gagal menghapus model",
"addConnectionToImport": "Tambahkan koneksi untuk mengaktifkan impor.",
"noModelsConfigured": "Tidak ada model yang dikonfigurasi",
"connectionCount": "{count} koneksi",
@@ -2403,7 +2417,9 @@
"waitingForOpenAIAuthorization": "Waiting for OpenAI authorization...",
"waitingForAntigravityAuthorization": "Waiting for Antigravity authorization...",
"waitingForIFlowAuthorization": "Waiting for iFlow authorization...",
"exchangingCodeForTokens": "Exchanging code for tokens..."
"exchangingCodeForTokens": "Exchanging code for tokens...",
"Authorization": "Otorisasi",
"Content-Disposition": "Disposisi Konten"
},
"landing": {
"brandName": "OmniRoute",
@@ -2806,5 +2822,64 @@
"thinking": "Thinking template",
"tool-calling": "Tool calling template",
"multi-turn": "Multi-turn template"
},
"templatePayloads": {
"simpleChat": {
"system": "Anda adalah asisten AI yang membantu.",
"userGreeting": "Halo! Apa yang bisa saya bantu hari ini?"
},
"streaming": {
"prompt": "Tulis cerita tentang"
},
"systemPrompt": {
"question": "Apa arti hidup?",
"systemInstruction": "Berikan jawaban yang bijaksana dan filosofis."
},
"thinking": {
"question": "Jelaskan komputasi kuantum"
},
"toolCalling": {
"cityNameDescription": "Nama kota untuk mengetahui cuaca",
"toolDescription": "Dapatkan cuaca terkini untuk suatu lokasi",
"userWeather": "Bagaimana cuaca di Tokyo?"
},
"multiTurn": {
"system": "Anda adalah asisten yang membantu.",
"assistantExample": "Saya akan dengan senang hati membantu Anda dalam hal itu.",
"userInitial": "Saya butuh bantuan",
"userFollowUp": "Bisakah Anda menjelaskannya lebih lanjut?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
File diff suppressed because it is too large Load Diff
+78 -3
View File
@@ -128,7 +128,15 @@
"idc": "idc",
"cloud-status-changed": "cloud-status-changed",
"where_used": "where_used",
"windowMs": "windowMs"
"windowMs": "windowMs",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "Lista consentita degli strumenti",
"TOOL_DENYLIST": "Elenco negati dello strumento",
"Failed to save pricing": "Impossibile salvare i prezzi",
"Failed to reset pricing": "Impossibile reimpostare i prezzi",
"apikey": "Chiave API",
"http": "HTTP"
},
"sidebar": {
"home": "Casa",
@@ -177,7 +185,9 @@
"agents": "Agenti",
"cliToolsShort": "Strumenti",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themes",
@@ -1398,6 +1408,10 @@
"chatCompletions": "Completamenti della chat",
"importingModels": "Importazione...",
"importFromModels": "Importa da /modelli",
"clearAllModels": "Cancella tutti i modelli",
"clearAllModelsConfirm": "Sei sicuro di voler rimuovere tutti i modelli per questo provider? Questa azione non può essere annullata.",
"clearAllModelsSuccess": "Tutti i modelli cancellati",
"clearAllModelsFailed": "Impossibile cancellare i modelli",
"addConnectionToImport": "Aggiungi una connessione per abilitare l'importazione.",
"noModelsConfigured": "Nessun modello configurato",
"connectionCount": "{count} connessione/i",
@@ -2403,7 +2417,9 @@
"waitingForOpenAIAuthorization": "Waiting for OpenAI authorization...",
"waitingForAntigravityAuthorization": "Waiting for Antigravity authorization...",
"waitingForIFlowAuthorization": "Waiting for iFlow authorization...",
"exchangingCodeForTokens": "Exchanging code for tokens..."
"exchangingCodeForTokens": "Exchanging code for tokens...",
"Authorization": "Autorizzazione",
"Content-Disposition": "Disposizione del contenuto"
},
"landing": {
"brandName": "OmniRoute",
@@ -2806,5 +2822,64 @@
"thinking": "Thinking template",
"tool-calling": "Tool calling template",
"multi-turn": "Multi-turn template"
},
"templatePayloads": {
"simpleChat": {
"system": "Sei un utile assistente AI.",
"userGreeting": "Ciao! Come posso aiutarti oggi?"
},
"streaming": {
"prompt": "Scrivi una storia su"
},
"systemPrompt": {
"question": "Qual è il significato della vita?",
"systemInstruction": "Fornisci una risposta ponderata e filosofica."
},
"thinking": {
"question": "Spiegare l'informatica quantistica"
},
"toolCalling": {
"cityNameDescription": "Il nome della città per la quale ottenere il meteo",
"toolDescription": "Ottieni il meteo attuale per una località",
"userWeather": "Che tempo fa a Tokio?"
},
"multiTurn": {
"system": "Sei un assistente utile.",
"assistantExample": "Sarei felice di aiutarti in questo.",
"userInitial": "Ho bisogno di aiuto con",
"userFollowUp": "Puoi approfondire questo argomento?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}
+78 -3
View File
@@ -128,7 +128,15 @@
"idc": "idc",
"cloud-status-changed": "cloud-status-changed",
"where_used": "where_used",
"windowMs": "windowMs"
"windowMs": "windowMs",
"social-github": "GitHub",
"social-google": "Google",
"TOOL_ALLOWLIST": "ツールの許可リスト",
"TOOL_DENYLIST": "ツール拒否リスト",
"Failed to save pricing": "価格設定を保存できませんでした",
"Failed to reset pricing": "価格設定のリセットに失敗しました",
"apikey": "APIキー",
"http": "HTTP"
},
"sidebar": {
"home": "ホーム",
@@ -177,7 +185,9 @@
"agents": "エージェント",
"cliToolsShort": "ツール",
"autoCombo": "Auto Combo",
"searchTools": "Search Tools"
"searchTools": "Search Tools",
"cache": "Cache",
"cacheShort": "Cache"
},
"themesPage": {
"title": "Themes",
@@ -1398,6 +1408,10 @@
"chatCompletions": "チャットの完了",
"importingModels": "インポート中...",
"importFromModels": "/models からインポート",
"clearAllModels": "すべてのモデルを削除",
"clearAllModelsConfirm": "このプロバイダーのすべてのモデルを削除してもよろしいですか?この操作は元に戻せません。",
"clearAllModelsSuccess": "すべてのモデルを削除しました",
"clearAllModelsFailed": "モデルの削除に失敗しました",
"addConnectionToImport": "接続を追加してインポートを有効にします。",
"noModelsConfigured": "モデルが設定されていません",
"connectionCount": "{count} 接続",
@@ -2403,7 +2417,9 @@
"waitingForOpenAIAuthorization": "Waiting for OpenAI authorization...",
"waitingForAntigravityAuthorization": "Waiting for Antigravity authorization...",
"waitingForIFlowAuthorization": "Waiting for iFlow authorization...",
"exchangingCodeForTokens": "Exchanging code for tokens..."
"exchangingCodeForTokens": "Exchanging code for tokens...",
"Authorization": "認可",
"Content-Disposition": "コンテンツの配置"
},
"landing": {
"brandName": "オムニルート",
@@ -2806,5 +2822,64 @@
"thinking": "Thinking template",
"tool-calling": "Tool calling template",
"multi-turn": "Multi-turn template"
},
"templatePayloads": {
"simpleChat": {
"system": "あなたは役に立つ AI アシスタントです。",
"userGreeting": "こんにちは!今日はどのようにお手伝いできますか?"
},
"streaming": {
"prompt": "~についての話を書いてください"
},
"systemPrompt": {
"question": "人生の意味とは何でしょうか?",
"systemInstruction": "思慮深く哲学的な答えを提供してください。"
},
"thinking": {
"question": "量子コンピューティングについて説明する"
},
"toolCalling": {
"cityNameDescription": "天気を取得する都市の名前",
"toolDescription": "場所の現在の天気を取得する",
"userWeather": "東京の天気はどうですか?"
},
"multiTurn": {
"system": "あなたは役に立つアシスタントです。",
"assistantExample": "喜んでお手伝いさせていただきます。",
"userInitial": "助けが必要です",
"userFollowUp": "それについて詳しく教えてもらえますか?"
}
},
"cache": {
"title": "Cache Management",
"description": "Monitor and manage semantic response cache, hit rates, and token savings.",
"refresh": "Refresh",
"clearAll": "Clear All",
"memoryEntries": "Memory Entries",
"dbEntries": "DB Entries",
"cacheHits": "Cache Hits",
"tokensSaved": "Tokens Saved",
"hitRate": "Hit Rate",
"performance": "Cache Performance",
"behavior": "Cache Behavior",
"idempotency": "Idempotency Layer",
"clearSuccess": "Cache cleared. {count} expired entries removed.",
"clearError": "Failed to clear cache.",
"unavailable": "Cache unavailable",
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
"memoryEntriesSub": "In-memory LRU",
"dbEntriesSub": "Persisted (SQLite)",
"cacheHitsSub": "of {total} total",
"tokensSavedSub": "Estimated from hits",
"autoRefresh": "Auto-refreshes every {seconds}s",
"hits": "Hits",
"misses": "Misses",
"total": "Total",
"behaviorDeterministic": "Only non-streaming requests with temperature=0 are cached.",
"behaviorBypass": "Bypass with header {header}.",
"behaviorTwoTier": "Two-tier storage: in-memory LRU (fast) + SQLite (persistent across restarts).",
"behaviorTtl": "Default TTL: 30 minutes. Configure via {envVar}.",
"activeDedupKeys": "Active Dedup Keys",
"dedupWindow": "Dedup Window"
}
}

Some files were not shown because too many files have changed in this diff Show More