mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-28 19:02:49 +03:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f171b7de96 | ||
|
|
c0cbf00199 | ||
|
|
0cd6e59fb9 | ||
|
|
11a8adc71c | ||
|
|
b9c7fd879f | ||
|
|
2fc4c7ea33 | ||
|
|
538028c150 | ||
|
|
fb8d187f8d | ||
|
|
1a11301e1a | ||
|
|
4c6cdd5c23 | ||
|
|
30a64b0dd3 | ||
|
|
04de492019 | ||
|
|
07890df6cb | ||
|
|
2f23cfdf1c | ||
|
|
1832946d41 | ||
|
|
6ec8745d2e | ||
|
|
b6bbfe063b | ||
|
|
48182edbd5 | ||
|
|
94a00cb6d6 | ||
|
|
fc24361aa6 | ||
|
|
cec833afc6 | ||
|
|
f1cddba938 | ||
|
|
a0acdfdcb9 | ||
|
|
6637f294df | ||
|
|
ad8a444105 | ||
|
|
877cfa0071 | ||
|
|
e6f0a780b7 | ||
|
|
dd9de2efa9 | ||
|
|
f6b0811f78 | ||
|
|
eba9d854a9 | ||
|
|
437cf9bab0 | ||
|
|
9ffad1005e | ||
|
|
65edddd62e | ||
|
|
a7cdcd8b3a | ||
|
|
3d6b85ed20 | ||
|
|
7abea2020c | ||
|
|
e16c34f0e3 | ||
|
|
4bfda6a145 | ||
|
|
98470e8551 | ||
|
|
df558ab8d6 | ||
|
|
c07372b58c | ||
|
|
00f59b95ae | ||
|
|
8915a7c2cd | ||
|
|
8595964ab8 | ||
|
|
922dae8546 | ||
|
|
69b3e23400 | ||
|
|
55325773dc | ||
|
|
b84c915b23 | ||
|
|
cfb390936a | ||
|
|
c5f344f333 | ||
|
|
ba4b496306 | ||
|
|
c48554589c | ||
|
|
da0851e21d | ||
|
|
d2d05abac0 | ||
|
|
de3e0423cc | ||
|
|
8d742d7938 | ||
|
|
682fd550fa | ||
|
|
abcf836a0c | ||
|
|
8ed452d9ea | ||
|
|
ae8d2ac2e1 | ||
|
|
93beb068a3 | ||
|
|
7e90b8b7be | ||
|
|
ed146fcf07 |
9
.github/workflows/docker-publish.yml
vendored
9
.github/workflows/docker-publish.yml
vendored
@@ -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
.github/workflows/npm-publish.yml
vendored
18
.github/workflows/npm-publish.yml
vendored
@@ -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
.gitignore
vendored
1
.gitignore
vendored
@@ -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/)
|
||||
|
||||
@@ -47,3 +47,12 @@ AGENTS.md
|
||||
# Build artifacts (pre-built goes inside app/)
|
||||
.next/
|
||||
node_modules/
|
||||
|
||||
# Ignore large binary files and other build directories
|
||||
*.tgz
|
||||
*.AppImage
|
||||
*.deb
|
||||
*.rpm
|
||||
electron/
|
||||
app/electron/
|
||||
app/vscode-extension/
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -2,6 +2,116 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.2.3] — 2026-03-29
|
||||
|
||||
### ✨ Enhancements & Refactoring
|
||||
|
||||
- **Provider Limits Quota UI (#728)** — Normalized quota limit logic and data labeling inside the Limits interface.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Core Routing Schemas & Leaks** — Expanded `comboStrategySchema` to natively support `fill-first` and `p2c` strategies to unblock complex combo editing natively.
|
||||
- **Thinking Tags Extraction (CLI)** — Restructured CLI token responses sanitizer RegEx capturing model reasoning structures inside streams avoiding broken `<thinking>` extractions breaking response text output format.
|
||||
- **Strict Format Enforcements** — Hardened pipeline sanitization execution making it universally apply to translation mode targets.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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
COVERAGE_PLAN.md
Normal file
166
COVERAGE_PLAN.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
2081
README.cs.md
Normal file
2081
README.cs.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.1.8
|
||||
version: 3.2.3
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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 as Record<string, unknown>) ?? undefined, {
|
||||
claudePromptCache: claudeCacheMeta,
|
||||
}),
|
||||
responseBody: attachLogMeta((responseBody as Record<string, unknown>) ?? 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)
|
||||
@@ -1160,8 +1464,9 @@ export async function handleChatCore({
|
||||
|
||||
// Sanitize response for OpenAI SDK compatibility
|
||||
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
|
||||
// Extracts <think> tags into reasoning_content
|
||||
if (sourceFormat === FORMATS.OPENAI) {
|
||||
// Extracts <think> and <thinking> tags into reasoning_content
|
||||
// Target format determines output shape. If we are outputting OpenAI shape or pseudo-OpenAI shape, sanitize.
|
||||
if (targetFormat === FORMATS.OPENAI || targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
translatedResponse = sanitizeOpenAIResponse(translatedResponse);
|
||||
}
|
||||
|
||||
@@ -1190,6 +1495,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 +1547,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",
|
||||
|
||||
@@ -32,13 +32,12 @@ function toNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
// ── Think tag regex ────────────────────────────────────────────────────────
|
||||
// Matches <think>...</think> blocks (greedy, dotAll)
|
||||
const THINK_TAG_REGEX = /<think>([\s\S]*?)<\/think>/gi;
|
||||
// Matches <think>...</think> blocks and <thinking>...</thinking> (greedy, dotAll)
|
||||
const THINK_TAG_REGEX = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi;
|
||||
|
||||
// #638: Collapse runs of 3+ consecutive newlines into \n\n
|
||||
// #638, #727: Collapse runs of 2+ consecutive newlines into \n\n
|
||||
// Tool call responses from thinking models often accumulate excessive newlines
|
||||
const EXCESSIVE_NEWLINES = /\n{3,}/g;
|
||||
const EXCESSIVE_NEWLINES = /\n{2,}/g;
|
||||
function collapseExcessiveNewlines(text: string): string {
|
||||
return text.replace(EXCESSIVE_NEWLINES, "\n\n");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
@@ -890,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) {
|
||||
@@ -1146,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) {
|
||||
@@ -1157,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) {
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
207
open-sse/translator/helpers/schemaCoercion.ts
Normal file
207
open-sse/translator/helpers/schemaCoercion.ts
Normal file
@@ -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: "" };
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -500,30 +528,47 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// Content for call log is accumulated only from parsed (above) to avoid double-counting;
|
||||
// do not add again from item here.
|
||||
|
||||
// #723, #727: Sanitize intermediate stream chunks if target is OpenAI format loop
|
||||
let itemSanitized: Record<string, unknown> = item;
|
||||
if (targetFormat === FORMATS.OPENAI || targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record<string, unknown>;
|
||||
|
||||
// Extract reasoning tags from content if translation generated them
|
||||
const delta = itemSanitized?.choices?.[0]?.delta;
|
||||
if (delta?.content && typeof delta.content === "string") {
|
||||
const { content, thinking } = extractThinkingFromContent(delta.content);
|
||||
delta.content = content;
|
||||
if (thinking && !delta.reasoning_content) {
|
||||
delta.reasoning_content = thinking;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter empty chunks
|
||||
if (!hasValuableContent(item, sourceFormat)) {
|
||||
if (!hasValuableContent(itemSanitized, sourceFormat)) {
|
||||
continue; // Skip this empty chunk
|
||||
}
|
||||
|
||||
// Inject estimated usage if finish chunk has no valid usage
|
||||
const isFinishChunk =
|
||||
item.type === "message_delta" || item.choices?.[0]?.finish_reason;
|
||||
itemSanitized.type === "message_delta" || itemSanitized.choices?.[0]?.finish_reason;
|
||||
if (
|
||||
state.finishReason &&
|
||||
isFinishChunk &&
|
||||
!hasValidUsage(item.usage) &&
|
||||
!hasValidUsage(itemSanitized.usage) &&
|
||||
totalContentLength > 0
|
||||
) {
|
||||
const estimated = estimateUsage(body, totalContentLength, sourceFormat);
|
||||
item.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer
|
||||
itemSanitized.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer
|
||||
state.usage = estimated;
|
||||
} else if (state.finishReason && isFinishChunk && state.usage) {
|
||||
// Add buffer and filter usage for client (but keep original in state.usage for logging)
|
||||
const buffered = addBufferToUsage(state.usage);
|
||||
item.usage = filterUsageForFormat(buffered, sourceFormat);
|
||||
itemSanitized.usage = filterUsageForFormat(buffered, sourceFormat);
|
||||
}
|
||||
|
||||
const output = formatSSE(item, sourceFormat);
|
||||
const output = formatSSE(itemSanitized, sourceFormat);
|
||||
clientPayloadCollector.push(itemSanitized);
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(encoder.encode(output));
|
||||
}
|
||||
@@ -551,6 +596,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 +651,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 +667,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 +704,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 +724,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 +743,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 +807,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) {
|
||||
|
||||
72
open-sse/utils/streamPayloadCollector.ts
Normal file
72
open-sse/utils/streamPayloadCollector.ts
Normal 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) }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
339
package-lock.json
generated
339
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.1.8",
|
||||
"version": "3.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.1.8",
|
||||
"version": "3.2.3",
|
||||
"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",
|
||||
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.1.8",
|
||||
"version": "3.2.3",
|
||||
"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
public/providers/windsurf.svg
Normal file
6
public/providers/windsurf.svg
Normal 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 |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
Normal file
340
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,13 @@ 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,
|
||||
formatQuotaLabel,
|
||||
normalizePlanTier,
|
||||
resolvePlanValue,
|
||||
} from "./utils";
|
||||
import Card from "@/shared/components/Card";
|
||||
import Badge from "@/shared/components/Badge";
|
||||
import { CardSkeleton } from "@/shared/components/Loading";
|
||||
@@ -16,6 +22,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 = {
|
||||
@@ -40,33 +48,14 @@ const TIER_FILTERS = [
|
||||
{ key: "unknown", labelKey: "tierUnknown" },
|
||||
];
|
||||
|
||||
// Short model display names for quota bars
|
||||
function getShortModelName(name) {
|
||||
const map = {
|
||||
"gemini-3-pro-high": "G3 Pro",
|
||||
"gemini-3-pro-low": "G3 Pro Low",
|
||||
"gemini-3-flash": "G3 Flash",
|
||||
"gemini-2.5-flash": "G2.5 Flash",
|
||||
"claude-opus-4-6-thinking": "Opus 4.6 Tk",
|
||||
"claude-opus-4-5-thinking": "Opus 4.5 Tk",
|
||||
"claude-opus-4-5": "Opus 4.5",
|
||||
"claude-sonnet-4-5-thinking": "Sonnet 4.5 Tk",
|
||||
"claude-sonnet-4-5": "Sonnet 4.5",
|
||||
chat: "Chat",
|
||||
completions: "Completions",
|
||||
premium_interactions: "Premium",
|
||||
session: "Session",
|
||||
weekly: "Weekly",
|
||||
agentic_request: "Agentic",
|
||||
agentic_request_freetrial: "Agentic (Trial)",
|
||||
};
|
||||
return map[name] || 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 +286,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 +310,7 @@ export default function ProviderLimits() {
|
||||
business: 0,
|
||||
ultra: 0,
|
||||
pro: 0,
|
||||
plus: 0,
|
||||
free: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
@@ -533,6 +531,7 @@ export default function ProviderLimits() {
|
||||
color: "#666",
|
||||
};
|
||||
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
|
||||
const resolvedPlan = resolvedPlanByConnection[conn.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -558,21 +557,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 +604,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 shortName = formatQuotaLabel(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 +641,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 +652,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,81 @@
|
||||
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",
|
||||
]);
|
||||
|
||||
const QUOTA_LABEL_MAP: Record<string, string> = {
|
||||
"gemini-3-pro-high": "G3 Pro",
|
||||
"gemini-3-pro-low": "G3 Pro Low",
|
||||
"gemini-3-flash": "G3 Flash",
|
||||
"gemini-2.5-flash": "G2.5 Flash",
|
||||
"claude-opus-4-6-thinking": "Opus 4.6 Tk",
|
||||
"claude-opus-4-5-thinking": "Opus 4.5 Tk",
|
||||
"claude-opus-4-5": "Opus 4.5",
|
||||
"claude-sonnet-4-5-thinking": "Sonnet 4.5 Tk",
|
||||
"claude-sonnet-4-5": "Sonnet 4.5",
|
||||
chat: "Chat",
|
||||
completions: "Completions",
|
||||
premium_interactions: "Premium",
|
||||
session: "Session",
|
||||
weekly: "Weekly",
|
||||
code_review: "Code Review",
|
||||
agentic_request: "Agentic",
|
||||
agentic_request_freetrial: "Agentic (Trial)",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function toTitleCaseWords(value: string) {
|
||||
return value
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function formatQuotaLabel(name: string) {
|
||||
const trimmed = typeof name === "string" ? name.trim() : "";
|
||||
if (!trimmed) return "";
|
||||
|
||||
const mapped = QUOTA_LABEL_MAP[trimmed];
|
||||
if (mapped) return mapped;
|
||||
|
||||
if (/^session\s*\(\d+[hm]\)$/i.test(trimmed)) {
|
||||
return "Session";
|
||||
}
|
||||
|
||||
if (/^weekly\s*\(\d+d\)$/i.test(trimmed)) {
|
||||
return "Weekly";
|
||||
}
|
||||
|
||||
const weeklyModelMatch = trimmed.match(/^weekly\s+(.+?)\s*\(\d+d\)$/i);
|
||||
if (weeklyModelMatch) {
|
||||
return `Weekly ${toTitleCaseWords(weeklyModelMatch[1])}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
|
||||
* @param {string|Date} date - ISO date string or Date object
|
||||
@@ -210,9 +285,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 +321,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 +343,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 +351,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 (
|
||||
|
||||
75
src/app/api/cache/route.ts
vendored
75
src/app/api/cache/route.ts
vendored
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
41
src/app/api/providers/[id]/codex-auth/apply-local/route.ts
Normal file
41
src/app/api/providers/[id]/codex-auth/apply-local/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
37
src/app/api/providers/[id]/codex-auth/export/route.ts
Normal file
37
src/app/api/providers/[id]/codex-auth/export/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -59,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" },
|
||||
@@ -141,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",
|
||||
@@ -318,6 +320,14 @@ 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 || [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -337,3 +337,16 @@ button .material-symbols-outlined,
|
||||
.traffic-light.green {
|
||||
background: var(--color-traffic-green);
|
||||
}
|
||||
|
||||
/* ── Mobile Layout Fixes (Issue #659) ── */
|
||||
@media (max-width: 768px) {
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
min-width: 600px; /* Prevent columns from crushing together */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "🇨🇳" },
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "وكلاء",
|
||||
"cliToolsShort": "أدوات",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "المواضيع",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "استكمالات الدردشة",
|
||||
"importingModels": "جارٍ الاستيراد...",
|
||||
"importFromModels": "الاستيراد من / النماذج",
|
||||
"clearAllModels": "مسح جميع النماذج",
|
||||
"clearAllModelsConfirm": "هل أنت متأكد أنك تريد إزالة جميع النماذج لهذا المزود؟ لا يمكن التراجع عن هذا.",
|
||||
"clearAllModelsSuccess": "تم مسح جميع النماذج",
|
||||
"clearAllModelsFailed": "فشل في مسح النماذج",
|
||||
"addConnectionToImport": "أضف اتصالاً لتمكين الاستيراد.",
|
||||
"noModelsConfigured": "لم يتم تكوين أي نماذج",
|
||||
"connectionCount": "{count} الاتصال (الاتصالات)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Агенти",
|
||||
"cliToolsShort": "Инструменти",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Теми",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Чат завършвания",
|
||||
"importingModels": "Импортиране...",
|
||||
"importFromModels": "Импортиране от /models",
|
||||
"clearAllModels": "Изчисти всички модели",
|
||||
"clearAllModelsConfirm": "Сигурни ли сте, че искате да премахнете всички модели за този доставчик? Това не може да бъде отменено.",
|
||||
"clearAllModelsSuccess": "Всички модели са изчистени",
|
||||
"clearAllModelsFailed": "Неуспешно изчистване на моделите",
|
||||
"addConnectionToImport": "Добавете връзка, за да активирате импортирането.",
|
||||
"noModelsConfigured": "Няма конфигурирани модели",
|
||||
"connectionCount": "{count} връзка(и)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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í",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,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",
|
||||
@@ -1406,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)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agentes",
|
||||
"cliToolsShort": "Herramientas",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Temas",
|
||||
@@ -1406,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)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agentit",
|
||||
"cliToolsShort": "Työkalut",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Teemat",
|
||||
@@ -1406,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ä",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agents",
|
||||
"cliToolsShort": "Outils",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Thèmes",
|
||||
@@ -1406,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)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "סוכנים",
|
||||
"cliToolsShort": "כלים",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "השלמת צ'אט",
|
||||
"importingModels": "מייבא...",
|
||||
"importFromModels": "ייבוא מ /models",
|
||||
"clearAllModels": "מחק את כל המודלים",
|
||||
"clearAllModelsConfirm": "האם אתה בטוח שברצונך להסיר את כל המודלים עבור ספק זה? לא ניתן לבטל פעולה זו.",
|
||||
"clearAllModelsSuccess": "כל המודלים נמחקו",
|
||||
"clearAllModelsFailed": "מחיקת המודלים נכשלה",
|
||||
"addConnectionToImport": "הוסף חיבור כדי לאפשר ייבוא.",
|
||||
"noModelsConfigured": "לא הוגדרו דגמים",
|
||||
"connectionCount": "{count} חיבור(ים)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,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",
|
||||
@@ -1406,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",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agen",
|
||||
"cliToolsShort": "Alat",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,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",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"themeViolet": "बैंगनी",
|
||||
"themeOrange": "नारंगी",
|
||||
"themeCyan": "सियान",
|
||||
"cliToolsShort": "उपकरण"
|
||||
"cliToolsShort": "उपकरण",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "थीम्स",
|
||||
@@ -2843,5 +2845,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenti",
|
||||
"cliToolsShort": "Strumenti",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,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",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "エージェント",
|
||||
"cliToolsShort": "ツール",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "チャットの完了",
|
||||
"importingModels": "インポート中...",
|
||||
"importFromModels": "/models からインポート",
|
||||
"clearAllModels": "すべてのモデルを削除",
|
||||
"clearAllModelsConfirm": "このプロバイダーのすべてのモデルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"clearAllModelsSuccess": "すべてのモデルを削除しました",
|
||||
"clearAllModelsFailed": "モデルの削除に失敗しました",
|
||||
"addConnectionToImport": "接続を追加してインポートを有効にします。",
|
||||
"noModelsConfigured": "モデルが設定されていません",
|
||||
"connectionCount": "{count} 接続",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "에이전트",
|
||||
"cliToolsShort": "도구",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "채팅 완료",
|
||||
"importingModels": "가져오는 중...",
|
||||
"importFromModels": "/models에서 가져오기",
|
||||
"clearAllModels": "모든 모델 지우기",
|
||||
"clearAllModelsConfirm": "이 제공자의 모든 모델을 제거하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"clearAllModelsSuccess": "모든 모델이 지워졌습니다",
|
||||
"clearAllModelsFailed": "모델 지우기 실패",
|
||||
"addConnectionToImport": "가져오기를 활성화하려면 연결을 추가하세요.",
|
||||
"noModelsConfigured": "구성된 모델이 없습니다.",
|
||||
"connectionCount": "{count} 연결",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Ejen",
|
||||
"cliToolsShort": "Alat",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Selesai Sembang",
|
||||
"importingModels": "Mengimport...",
|
||||
"importFromModels": "Import daripada /models",
|
||||
"clearAllModels": "Padam Semua Model",
|
||||
"clearAllModelsConfirm": "Adakah anda pasti mahu membuang semua model untuk pembekal ini? Tindakan ini tidak boleh dibuat asal.",
|
||||
"clearAllModelsSuccess": "Semua model dipadamkan",
|
||||
"clearAllModelsFailed": "Gagal memadamkan model",
|
||||
"addConnectionToImport": "Tambahkan sambungan untuk mendayakan pengimportan.",
|
||||
"noModelsConfigured": "Tiada model yang dikonfigurasikan",
|
||||
"connectionCount": "{count} sambungan",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Saya perlukan bantuan",
|
||||
"userFollowUp": "Bolehkah anda menghuraikan perkara itu?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenten",
|
||||
"cliToolsShort": "Gereedschap",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Chat-voltooiingen",
|
||||
"importingModels": "Importeren...",
|
||||
"importFromModels": "Importeren uit /modellen",
|
||||
"clearAllModels": "Alle modellen wissen",
|
||||
"clearAllModelsConfirm": "Weet je zeker dat je alle modellen voor deze provider wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"clearAllModelsSuccess": "Alle modellen gewist",
|
||||
"clearAllModelsFailed": "Kon modellen niet wissen",
|
||||
"addConnectionToImport": "Voeg een verbinding toe om importeren mogelijk te maken.",
|
||||
"noModelsConfigured": "Geen modellen geconfigureerd",
|
||||
"connectionCount": "{count} verbinding(en)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Ik heb hulp nodig bij",
|
||||
"userFollowUp": "Kunt u dat nader toelichten?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenter",
|
||||
"cliToolsShort": "Verktøy",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Chatfullføringer",
|
||||
"importingModels": "Importerer...",
|
||||
"importFromModels": "Importer fra /models",
|
||||
"clearAllModels": "Slett alle modeller",
|
||||
"clearAllModelsConfirm": "Er du sikker på at du vil fjerne alle modeller for denne leverandøren? Dette kan ikke angres.",
|
||||
"clearAllModelsSuccess": "Alle modeller slettet",
|
||||
"clearAllModelsFailed": "Kunne ikke slette modeller",
|
||||
"addConnectionToImport": "Legg til en tilkobling for å aktivere import.",
|
||||
"noModelsConfigured": "Ingen modeller er konfigurert",
|
||||
"connectionCount": "{count} tilkobling(er)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Jeg trenger hjelp med",
|
||||
"userFollowUp": "Kan du utdype 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Mga Agent",
|
||||
"cliToolsShort": "Mga Tool",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Mga Pagkumpleto ng Chat",
|
||||
"importingModels": "Ini-import...",
|
||||
"importFromModels": "Mag-import mula sa /models",
|
||||
"clearAllModels": "Burahin Lahat ng Modelo",
|
||||
"clearAllModelsConfirm": "Sigurado ka bang gusto mong alisin lahat ng modelo para sa provider na ito? Hindi na ito mababawi.",
|
||||
"clearAllModelsSuccess": "Lahat ng modelo ay nabura",
|
||||
"clearAllModelsFailed": "Nabigong burahin ang mga modelo",
|
||||
"addConnectionToImport": "Magdagdag ng koneksyon upang paganahin ang pag-import.",
|
||||
"noModelsConfigured": "Walang mga modelong na-configure",
|
||||
"connectionCount": "{count} (mga) koneksyon",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Kailangan ko ng tulong sa",
|
||||
"userFollowUp": "Maaari mo bang ipaliwanag iyon?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenci",
|
||||
"cliToolsShort": "Narzędzia",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Zakończenia czatu",
|
||||
"importingModels": "Importowanie...",
|
||||
"importFromModels": "Importuj z /modele",
|
||||
"clearAllModels": "Wyczyść wszystkie modele",
|
||||
"clearAllModelsConfirm": "Czy na pewno chcesz usunąć wszystkie modele dla tego dostawcy? Tej operacji nie można cofnąć.",
|
||||
"clearAllModelsSuccess": "Wszystkie modele wyczyszczone",
|
||||
"clearAllModelsFailed": "Nie udało się wyczyścić modeli",
|
||||
"addConnectionToImport": "Dodaj połączenie, aby umożliwić importowanie.",
|
||||
"noModelsConfigured": "Nie skonfigurowano żadnych modeli",
|
||||
"connectionCount": "{count} połączenia",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Potrzebuję pomocy",
|
||||
"userFollowUp": "Możesz to rozwinąć?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"playground": "Playground",
|
||||
"agents": "Agentes",
|
||||
"cliToolsShort": "Ferramentas",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1418,6 +1420,10 @@
|
||||
"chatCompletions": "Chat Completions",
|
||||
"importingModels": "Importando...",
|
||||
"importFromModels": "Importar de /models",
|
||||
"clearAllModels": "Limpar Todos os Modelos",
|
||||
"clearAllModelsConfirm": "Tem certeza de que deseja remover todos os modelos deste provedor? Esta ação não pode ser desfeita.",
|
||||
"clearAllModelsSuccess": "Todos os modelos foram limpos",
|
||||
"clearAllModelsFailed": "Falha ao limpar os modelos",
|
||||
"addConnectionToImport": "Adicione uma conexão para habilitar importação.",
|
||||
"noModelsConfigured": "Nenhum modelo configurado",
|
||||
"connectionCount": "{count} conexão(ões)",
|
||||
@@ -1575,6 +1581,12 @@
|
||||
"compatProtocolClaude": "Anthropic Messages",
|
||||
"tokenRefreshed": "Token refreshed successfully",
|
||||
"tokenRefreshFailed": "Token refresh failed",
|
||||
"applyCodexAuthLocal": "Aplicar auth",
|
||||
"exportCodexAuthFile": "Exportar auth",
|
||||
"codexAuthAppliedLocal": "auth.json do Codex aplicado localmente",
|
||||
"codexAuthApplyFailed": "Falha ao aplicar o auth.json do Codex localmente",
|
||||
"codexAuthExported": "auth.json do Codex exportado",
|
||||
"codexAuthExportFailed": "Falha ao exportar o auth.json do Codex",
|
||||
"compatBadgeUpstreamHeaders": "Headers",
|
||||
"compatUpstreamAddRow": "Add header",
|
||||
"compatUpstreamHeaderName": "Header name",
|
||||
@@ -2855,5 +2867,37 @@
|
||||
"userInitial": "preciso de ajuda com",
|
||||
"userFollowUp": "Você pode explicar isso?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agentes",
|
||||
"cliToolsShort": "Ferramentas",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1418,6 +1420,10 @@
|
||||
"chatCompletions": "Conclusões de bate-papo",
|
||||
"importingModels": "Importando...",
|
||||
"importFromModels": "Importar de /modelos",
|
||||
"clearAllModels": "Limpar Todos os Modelos",
|
||||
"clearAllModelsConfirm": "Tem a certeza de que quer remover todos os modelos deste fornecedor? Esta ação não pode ser revertida.",
|
||||
"clearAllModelsSuccess": "Todos os modelos foram limpos",
|
||||
"clearAllModelsFailed": "Falha ao limpar os modelos",
|
||||
"addConnectionToImport": "Adicione uma conexão para permitir a importação.",
|
||||
"noModelsConfigured": "Nenhum modelo configurado",
|
||||
"connectionCount": "{count} conexões",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "preciso de ajuda com",
|
||||
"userFollowUp": "Você pode explicar isso?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenți",
|
||||
"cliToolsShort": "Instrumente",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Finalizări de chat",
|
||||
"importingModels": "Se importă...",
|
||||
"importFromModels": "Import din /modele",
|
||||
"clearAllModels": "Șterge toate modelele",
|
||||
"clearAllModelsConfirm": "Ești sigur că vrei să elimini toate modelele pentru acest furnizor? Această acțiune nu poate fi anulată.",
|
||||
"clearAllModelsSuccess": "Toate modelele au fost șterse",
|
||||
"clearAllModelsFailed": "Ștergerea modelelor a eșuat",
|
||||
"addConnectionToImport": "Adăugați o conexiune pentru a activa importul.",
|
||||
"noModelsConfigured": "Nu au fost configurate modele",
|
||||
"connectionCount": "{count} conexiuni",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Am nevoie de ajutor cu",
|
||||
"userFollowUp": "Puteți detalia asta?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Агенты",
|
||||
"cliToolsShort": "Инструменты",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Темы",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Завершения чата",
|
||||
"importingModels": "Импорт...",
|
||||
"importFromModels": "Импорт из /модели",
|
||||
"clearAllModels": "Очистить все модели",
|
||||
"clearAllModelsConfirm": "Вы уверены, что хотите удалить все модели для этого провайдера? Это действие нельзя отменить.",
|
||||
"clearAllModelsSuccess": "Все модели очищены",
|
||||
"clearAllModelsFailed": "Не удалось очистить модели",
|
||||
"addConnectionToImport": "Добавьте соединение, чтобы включить импорт.",
|
||||
"noModelsConfigured": "Ни одна модель не настроена",
|
||||
"connectionCount": "{count} соединение(я)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenti",
|
||||
"cliToolsShort": "Nástroje",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Dokončenia četu",
|
||||
"importingModels": "Importuje sa...",
|
||||
"importFromModels": "Importovať z /models",
|
||||
"clearAllModels": "Vymazať všetky modely",
|
||||
"clearAllModelsConfirm": "Ste si istý, že chcete odstrániť všetky modely pre tohto poskytovateľa? Túto akciu nie je možné vrátiť.",
|
||||
"clearAllModelsSuccess": "Všetky modely vymazané",
|
||||
"clearAllModelsFailed": "Nepodarilo sa vymazať modely",
|
||||
"addConnectionToImport": "Ak chcete povoliť import, pridajte pripojenie.",
|
||||
"noModelsConfigured": "Nie sú nakonfigurované žiadne modely",
|
||||
"connectionCount": "{count} pripojení",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Potrebujem pomoc s",
|
||||
"userFollowUp": "Môžete to upresniť?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Agenter",
|
||||
"cliToolsShort": "Verktyg",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Chattavslut",
|
||||
"importingModels": "Importerar...",
|
||||
"importFromModels": "Importera från /models",
|
||||
"clearAllModels": "Rensa alla modeller",
|
||||
"clearAllModelsConfirm": "Är du säker på att du vill ta bort alla modeller för denna leverantör? Detta kan inte ångras.",
|
||||
"clearAllModelsSuccess": "Alla modeller rensade",
|
||||
"clearAllModelsFailed": "Kunde inte rensa modeller",
|
||||
"addConnectionToImport": "Lägg till en anslutning för att aktivera import.",
|
||||
"noModelsConfigured": "Inga modeller har konfigurerats",
|
||||
"connectionCount": "{count} anslutning(ar)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Jag behöver hjälp med",
|
||||
"userFollowUp": "Kan du utveckla 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "เอเจนต์",
|
||||
"cliToolsShort": "เครื่องมือ",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "ธีมส์",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "เสร็จสิ้นการแชท",
|
||||
"importingModels": "กำลังนำเข้า...",
|
||||
"importFromModels": "นำเข้าจาก /models",
|
||||
"clearAllModels": "ล้างโมเดลทั้งหมด",
|
||||
"clearAllModelsConfirm": "คุณแน่ใจหรือไม่ว่าต้องการลบโมเดลทั้งหมดของผู้ให้บริการนี้? การดำเนินการนี้ไม่สามารถย้อนกลับได้",
|
||||
"clearAllModelsSuccess": "ล้างโมเดลทั้งหมดแล้ว",
|
||||
"clearAllModelsFailed": "ไม่สามารถล้างโมเดลได้",
|
||||
"addConnectionToImport": "เพิ่มการเชื่อมต่อเพื่อเปิดใช้งานการนำเข้า",
|
||||
"noModelsConfigured": "ไม่มีโมเดลที่กำหนดค่าไว้",
|
||||
"connectionCount": "{count} การเชื่อมต่อ",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
2847
src/i18n/messages/tr.json
Normal file
2847
src/i18n/messages/tr.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -185,7 +185,9 @@
|
||||
"agents": "Агенти",
|
||||
"cliToolsShort": "Інструменти",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Завершення чату",
|
||||
"importingModels": "Імпорт...",
|
||||
"importFromModels": "Імпортувати з /models",
|
||||
"clearAllModels": "Очистити всі моделі",
|
||||
"clearAllModelsConfirm": "Ви впевнені, що хочете видалити всі моделі для цього провайдера? Цю дію неможливо скасувати.",
|
||||
"clearAllModelsSuccess": "Усі моделі очищено",
|
||||
"clearAllModelsFailed": "Не вдалося очистити моделі",
|
||||
"addConnectionToImport": "Додайте підключення, щоб увімкнути імпорт.",
|
||||
"noModelsConfigured": "Немає налаштованих моделей",
|
||||
"connectionCount": "{count} підключення(-я)",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"agents": "Tác nhân",
|
||||
"cliToolsShort": "Công cụ",
|
||||
"autoCombo": "Auto Combo",
|
||||
"searchTools": "Search Tools"
|
||||
"searchTools": "Search Tools",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "Themes",
|
||||
@@ -1406,6 +1408,10 @@
|
||||
"chatCompletions": "Hoàn thành cuộc trò chuyện",
|
||||
"importingModels": "Đang nhập khẩu...",
|
||||
"importFromModels": "Nhập từ /model",
|
||||
"clearAllModels": "Xóa tất cả mô hình",
|
||||
"clearAllModelsConfirm": "Bạn có chắc chắn muốn xóa tất cả mô hình của nhà cung cấp này? Hành động này không thể hoàn tác.",
|
||||
"clearAllModelsSuccess": "Đã xóa tất cả mô hình",
|
||||
"clearAllModelsFailed": "Không thể xóa mô hình",
|
||||
"addConnectionToImport": "Thêm kết nối để cho phép nhập.",
|
||||
"noModelsConfigured": "Không có mô hình nào được định cấu hình",
|
||||
"connectionCount": "{count} kết nối",
|
||||
@@ -2843,5 +2849,37 @@
|
||||
"userInitial": "Tôi cần giúp đỡ với",
|
||||
"userFollowUp": "Bạn có thể giải thích về điều đó?"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@
|
||||
"themeViolet": "紫罗兰",
|
||||
"themeOrange": "橙色",
|
||||
"themeCyan": "青色",
|
||||
"cliToolsShort": "工具"
|
||||
"cliToolsShort": "工具",
|
||||
"cache": "Cache",
|
||||
"cacheShort": "Cache"
|
||||
},
|
||||
"themesPage": {
|
||||
"title": "主题",
|
||||
@@ -2843,5 +2845,37 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,8 +224,11 @@ export class A2ATaskManager {
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _manager: A2ATaskManager | null = null;
|
||||
const globalForA2A = globalThis as unknown as { _a2aTaskManager?: A2ATaskManager };
|
||||
|
||||
export function getTaskManager(): A2ATaskManager {
|
||||
if (!_manager) _manager = new A2ATaskManager();
|
||||
return _manager;
|
||||
if (!globalForA2A._a2aTaskManager) {
|
||||
globalForA2A._a2aTaskManager = new A2ATaskManager();
|
||||
}
|
||||
return globalForA2A._a2aTaskManager;
|
||||
}
|
||||
|
||||
74
src/lib/combos/testHealth.ts
Normal file
74
src/lib/combos/testHealth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { validateProviderApiKey } from "@/lib/providers/validation";
|
||||
import { getProviderCredentials } from "@/sse/services/auth";
|
||||
import { getModelInfo } from "@/sse/services/model";
|
||||
|
||||
const SOFT_REACHABILITY_STATUSES = new Set([400, 405, 406, 409, 422]);
|
||||
|
||||
export function buildComboTestRequestBody(modelStr: string) {
|
||||
return {
|
||||
model: modelStr,
|
||||
messages: [{ role: "user", content: "Reply with OK only." }],
|
||||
// Some gateway-routed models reject ultra-tiny budgets during smoke tests.
|
||||
max_tokens: 16,
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldProbeComboTestReachability(statusCode: number) {
|
||||
return SOFT_REACHABILITY_STATUSES.has(Number(statusCode));
|
||||
}
|
||||
|
||||
type ProbeDeps = {
|
||||
getModelInfo?: typeof getModelInfo;
|
||||
getProviderCredentials?: typeof getProviderCredentials;
|
||||
validateProviderApiKey?: typeof validateProviderApiKey;
|
||||
};
|
||||
|
||||
export async function probeComboModelReachability(modelStr: string, deps: ProbeDeps = {}) {
|
||||
const resolveModel = deps.getModelInfo || getModelInfo;
|
||||
const loadCredentials = deps.getProviderCredentials || getProviderCredentials;
|
||||
const validateKey = deps.validateProviderApiKey || validateProviderApiKey;
|
||||
|
||||
const modelInfo = await resolveModel(modelStr);
|
||||
if (!modelInfo?.provider) {
|
||||
return { reachable: false, reason: "unresolved_model" };
|
||||
}
|
||||
|
||||
const credentials = await loadCredentials(
|
||||
modelInfo.provider,
|
||||
null,
|
||||
null,
|
||||
modelInfo.model || modelStr
|
||||
);
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
return { reachable: false, reason: "credentials_unavailable" };
|
||||
}
|
||||
|
||||
const apiKey = credentials.apiKey || credentials.accessToken;
|
||||
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
|
||||
return { reachable: false, reason: "missing_auth_material" };
|
||||
}
|
||||
|
||||
const providerSpecificData =
|
||||
credentials.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
||||
? { ...credentials.providerSpecificData }
|
||||
: {};
|
||||
|
||||
if (!providerSpecificData.validationModelId && modelInfo.model) {
|
||||
providerSpecificData.validationModelId = modelInfo.model;
|
||||
}
|
||||
|
||||
const validation = await validateKey({
|
||||
provider: modelInfo.provider,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
});
|
||||
|
||||
return {
|
||||
reachable: Boolean(validation?.valid),
|
||||
provider: modelInfo.provider,
|
||||
model: modelInfo.model || null,
|
||||
method: validation?.method || null,
|
||||
warning: validation?.warning || null,
|
||||
};
|
||||
}
|
||||
@@ -8,20 +8,28 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance } from "./core";
|
||||
import { getSettings } from "./settings";
|
||||
import { isNoLog } from "../compliance";
|
||||
import {
|
||||
protectPayloadForLog,
|
||||
serializePayloadForStorage,
|
||||
parseStoredPayload,
|
||||
} from "../logPayloads";
|
||||
|
||||
export interface RequestDetailLog {
|
||||
id?: string;
|
||||
call_log_id?: string | null;
|
||||
timestamp?: string;
|
||||
client_request?: string | null;
|
||||
translated_request?: string | null;
|
||||
provider_response?: string | null;
|
||||
client_response?: string | null;
|
||||
client_request?: unknown | null;
|
||||
translated_request?: unknown | null;
|
||||
provider_response?: unknown | null;
|
||||
client_response?: unknown | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
source_format?: string | null;
|
||||
target_format?: string | null;
|
||||
duration_ms?: number;
|
||||
api_key_id?: string | null;
|
||||
no_log?: boolean;
|
||||
}
|
||||
|
||||
/** Returns true if detailed logging is enabled in settings */
|
||||
@@ -37,16 +45,14 @@ export async function isDetailedLoggingEnabled(): Promise<boolean> {
|
||||
|
||||
/** Save a detailed log entry — caller must verify isDetailedLoggingEnabled() first */
|
||||
export function saveRequestDetailLog(entry: RequestDetailLog): void {
|
||||
const noLogEnabled =
|
||||
Boolean(entry.no_log) || (entry.api_key_id ? isNoLog(entry.api_key_id) : false);
|
||||
if (noLogEnabled) return;
|
||||
|
||||
const db = getDbInstance();
|
||||
const id = entry.id ?? uuidv4();
|
||||
const timestamp = entry.timestamp ?? new Date().toISOString();
|
||||
|
||||
// Trim large bodies to avoid excessive disk usage (max 64KB each)
|
||||
const trim = (s: string | null | undefined, max = 65536): string | null => {
|
||||
if (!s) return null;
|
||||
return s.length > max ? s.slice(0, max) + "…[truncated]" : s;
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO request_detail_logs
|
||||
@@ -58,10 +64,10 @@ export function saveRequestDetailLog(entry: RequestDetailLog): void {
|
||||
id,
|
||||
entry.call_log_id ?? null,
|
||||
timestamp,
|
||||
trim(entry.client_request),
|
||||
trim(entry.translated_request),
|
||||
trim(entry.provider_response),
|
||||
trim(entry.client_response),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.client_request)),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.translated_request)),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.provider_response)),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.client_response)),
|
||||
entry.provider ?? null,
|
||||
entry.model ?? null,
|
||||
entry.source_format ?? null,
|
||||
@@ -73,7 +79,7 @@ export function saveRequestDetailLog(entry: RequestDetailLog): void {
|
||||
/** Fetch detailed logs (latest first) */
|
||||
export function getRequestDetailLogs(limit = 50, offset = 0): RequestDetailLog[] {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM request_detail_logs
|
||||
@@ -81,14 +87,34 @@ export function getRequestDetailLogs(limit = 50, offset = 0): RequestDetailLog[]
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
)
|
||||
.all(limit, offset) as RequestDetailLog[];
|
||||
.all(limit, offset) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map(mapDetailedLogRow);
|
||||
}
|
||||
|
||||
/** Get a single detailed log by ID */
|
||||
export function getRequestDetailLogById(id: string): RequestDetailLog | null {
|
||||
const db = getDbInstance();
|
||||
return (db.prepare("SELECT * FROM request_detail_logs WHERE id = ?").get(id) ??
|
||||
null) as RequestDetailLog | null;
|
||||
const row = db.prepare("SELECT * FROM request_detail_logs WHERE id = ?").get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? mapDetailedLogRow(row) : null;
|
||||
}
|
||||
|
||||
/** Get the most recent detailed log for a call log ID */
|
||||
export function getRequestDetailLogByCallLogId(callLogId: string): RequestDetailLog | null {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM request_detail_logs
|
||||
WHERE call_log_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(callLogId) as Record<string, unknown> | undefined;
|
||||
return row ? mapDetailedLogRow(row) : null;
|
||||
}
|
||||
|
||||
/** Get total count of detailed logs */
|
||||
@@ -99,3 +125,20 @@ export function getRequestDetailLogCount(): number {
|
||||
};
|
||||
return row?.cnt ?? 0;
|
||||
}
|
||||
|
||||
function mapDetailedLogRow(row: Record<string, unknown>): RequestDetailLog {
|
||||
return {
|
||||
id: typeof row.id === "string" ? row.id : undefined,
|
||||
call_log_id: typeof row.call_log_id === "string" ? row.call_log_id : null,
|
||||
timestamp: typeof row.timestamp === "string" ? row.timestamp : undefined,
|
||||
client_request: parseStoredPayload(row.client_request),
|
||||
translated_request: parseStoredPayload(row.translated_request),
|
||||
provider_response: parseStoredPayload(row.provider_response),
|
||||
client_response: parseStoredPayload(row.client_response),
|
||||
provider: typeof row.provider === "string" ? row.provider : null,
|
||||
model: typeof row.model === "string" ? row.model : null,
|
||||
source_format: typeof row.source_format === "string" ? row.source_format : null,
|
||||
target_format: typeof row.target_format === "string" ? row.target_format : null,
|
||||
duration_ms: typeof row.duration_ms === "number" ? row.duration_ms : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
import { startModelSyncScheduler } from "@/shared/services/modelSyncScheduler";
|
||||
import "@/lib/tokenHealthCheck"; // Proactive token health-check scheduler
|
||||
|
||||
// Initialize cloud sync when this module is imported
|
||||
// Initialize background sync services when this module is imported
|
||||
let initialized = false;
|
||||
|
||||
export async function ensureCloudSyncInitialized() {
|
||||
if (!initialized) {
|
||||
try {
|
||||
await initializeCloudSync();
|
||||
startModelSyncScheduler();
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[ServerInit] Error initializing cloud sync:", error);
|
||||
console.error("[ServerInit] Error initializing background sync services:", error);
|
||||
}
|
||||
}
|
||||
return initialized;
|
||||
|
||||
109
src/lib/logPayloads.ts
Normal file
109
src/lib/logPayloads.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { sanitizePII } from "./piiSanitizer";
|
||||
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"api_key",
|
||||
"apiKey",
|
||||
"api-key",
|
||||
"authorization",
|
||||
"Authorization",
|
||||
"x-api-key",
|
||||
"X-Api-Key",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"refresh_token",
|
||||
"refreshToken",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
]);
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function cloneLogPayload<T>(value: T): T {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function normalizePayloadForLog(payload: unknown): unknown {
|
||||
if (typeof payload !== "string") return payload;
|
||||
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { _rawText: payload };
|
||||
}
|
||||
}
|
||||
|
||||
export function redactPayload(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object") return payload;
|
||||
if (Array.isArray(payload)) return payload.map(redactPayload);
|
||||
|
||||
const redacted: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
redacted[key] = "[REDACTED]";
|
||||
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
|
||||
redacted[key] = "Bearer [REDACTED]";
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
redacted[key] = redactPayload(value);
|
||||
} else {
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function sanitizePayloadPII(payload: unknown): unknown {
|
||||
if (typeof payload === "string") {
|
||||
return sanitizePII(payload).text;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map(sanitizePayloadPII);
|
||||
}
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const sanitized: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
sanitized[key] = sanitizePayloadPII(value);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function protectPayloadForLog(payload: unknown): unknown {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
const normalized = normalizePayloadForLog(payload);
|
||||
const piiSanitized = sanitizePayloadPII(normalized);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
export function serializePayloadForStorage(payload: unknown, maxLength = 65536): string | null {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
|
||||
const exact = JSON.stringify(payload);
|
||||
if (exact.length <= maxLength) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
_truncated: true,
|
||||
_originalSize: exact.length,
|
||||
_preview: exact.slice(0, maxLength),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseStoredPayload(value: unknown): unknown | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return { _rawText: value };
|
||||
}
|
||||
}
|
||||
298
src/lib/oauth/utils/codexAuthFile.ts
Normal file
298
src/lib/oauth/utils/codexAuthFile.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { getProviderConnectionById } from "@/lib/localDb";
|
||||
import { createBackup } from "@/shared/services/backupService";
|
||||
import { getCliConfigPaths } from "@/shared/services/cliRuntime";
|
||||
import {
|
||||
TOKEN_EXPIRY_BUFFER_MS,
|
||||
getAccessToken,
|
||||
updateProviderCredentials,
|
||||
} from "@/sse/services/tokenRefresh";
|
||||
import { isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface CodexConnectionLike {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
authType?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
accessToken?: string | null;
|
||||
refreshToken?: string | null;
|
||||
idToken?: string | null;
|
||||
expiresAt?: string | null;
|
||||
expiresIn?: number | null;
|
||||
providerSpecificData?: JsonRecord | null;
|
||||
}
|
||||
|
||||
export interface CodexAuthFilePayload {
|
||||
auth_mode: "chatgpt";
|
||||
OPENAI_API_KEY: null;
|
||||
tokens: {
|
||||
id_token: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
account_id: string;
|
||||
};
|
||||
last_refresh: string;
|
||||
}
|
||||
|
||||
export interface BuiltCodexAuthFile {
|
||||
connectionId: string;
|
||||
connectionLabel: string;
|
||||
fileName: string;
|
||||
payload: CodexAuthFilePayload;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class CodexAuthFileError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
|
||||
constructor(message: string, status = 400, code = "invalid_request") {
|
||||
super(message);
|
||||
this.name = "CodexAuthFileError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_REFRESH_BUFFER_MS = Math.max(TOKEN_EXPIRY_BUFFER_MS, 5 * 60 * 1000);
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(jwt: string): JsonRecord | null {
|
||||
try {
|
||||
const parts = jwt.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = Buffer.from(parts[1], "base64url").toString("utf8");
|
||||
return toRecord(JSON.parse(payload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCodexAccountId(idToken: string, providerSpecificData: unknown): string | null {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
const authInfo = payload ? toRecord(payload["https://api.openai.com/auth"]) : {};
|
||||
|
||||
return (
|
||||
toNonEmptyString(authInfo.chatgpt_account_id) ||
|
||||
toNonEmptyString(authInfo.account_id) ||
|
||||
toNonEmptyString(toRecord(providerSpecificData).workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRefreshCodexConnection(connection: CodexConnectionLike): boolean {
|
||||
if (!toNonEmptyString(connection.accessToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = toNonEmptyString(connection.expiresAt);
|
||||
if (!expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiresAtMs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expiresAtMs - Date.now() <= CODEX_REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
function getConnectionLabel(connection: CodexConnectionLike): string {
|
||||
return (
|
||||
toNonEmptyString(connection.name) ||
|
||||
toNonEmptyString(connection.email) ||
|
||||
toNonEmptyString(connection.displayName) ||
|
||||
toNonEmptyString(connection.id) ||
|
||||
"codex-account"
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeFileNamePart(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return normalized || "account";
|
||||
}
|
||||
|
||||
function buildCodexAuthPayload(connection: CodexConnectionLike): CodexAuthFilePayload {
|
||||
const idToken = toNonEmptyString(connection.idToken);
|
||||
const accessToken = toNonEmptyString(connection.accessToken);
|
||||
const refreshToken = toNonEmptyString(connection.refreshToken);
|
||||
|
||||
if (!idToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection is missing id_token. Re-authenticate this account before exporting.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection is missing access_token. Refresh or re-authenticate this account first.",
|
||||
409,
|
||||
"access_token_missing"
|
||||
);
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection is missing refresh_token. Re-authenticate this account before exporting.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
const accountId = extractCodexAccountId(idToken, connection.providerSpecificData);
|
||||
if (!accountId) {
|
||||
throw new CodexAuthFileError(
|
||||
"Unable to derive Codex account_id from the stored session. Re-authenticate this account.",
|
||||
409,
|
||||
"account_id_missing"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: idToken,
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
account_id: accountId,
|
||||
},
|
||||
last_refresh: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveFreshCodexConnection(connectionId: string): Promise<CodexConnectionLike> {
|
||||
const connection = (await getProviderConnectionById(connectionId)) as CodexConnectionLike | null;
|
||||
if (!connection) {
|
||||
throw new CodexAuthFileError("Connection not found", 404, "not_found");
|
||||
}
|
||||
|
||||
if (connection.provider !== "codex") {
|
||||
throw new CodexAuthFileError("Only Codex provider connections can export Codex auth files");
|
||||
}
|
||||
|
||||
if (connection.authType !== "oauth") {
|
||||
throw new CodexAuthFileError("Only OAuth Codex connections support auth.json export");
|
||||
}
|
||||
|
||||
if (!shouldRefreshCodexConnection(connection)) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
const refreshToken = toNonEmptyString(connection.refreshToken);
|
||||
if (!refreshToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex connection requires refresh but no refresh_token is available. Re-authenticate first.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
const refreshed = await getAccessToken("codex", {
|
||||
connectionId,
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
expiresIn: connection.expiresIn,
|
||||
idToken: connection.idToken,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
});
|
||||
|
||||
if (isUnrecoverableRefreshError(refreshed)) {
|
||||
throw new CodexAuthFileError(
|
||||
"Codex refresh token is no longer valid. Re-authenticate this account before exporting.",
|
||||
409,
|
||||
"reauth_required"
|
||||
);
|
||||
}
|
||||
|
||||
if (!refreshed?.accessToken) {
|
||||
throw new CodexAuthFileError(
|
||||
"Failed to refresh the Codex session before exporting the auth file. Re-authenticate this account if the session is stale.",
|
||||
502,
|
||||
"refresh_failed"
|
||||
);
|
||||
}
|
||||
|
||||
await updateProviderCredentials(connectionId, refreshed);
|
||||
|
||||
return {
|
||||
...connection,
|
||||
accessToken: refreshed.accessToken,
|
||||
refreshToken: toNonEmptyString(refreshed.refreshToken) || refreshToken,
|
||||
expiresIn:
|
||||
typeof refreshed.expiresIn === "number" ? refreshed.expiresIn : connection.expiresIn || null,
|
||||
expiresAt:
|
||||
typeof refreshed.expiresIn === "number"
|
||||
? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString()
|
||||
: connection.expiresAt || null,
|
||||
providerSpecificData: refreshed.providerSpecificData
|
||||
? {
|
||||
...toRecord(connection.providerSpecificData),
|
||||
...toRecord(refreshed.providerSpecificData),
|
||||
}
|
||||
: connection.providerSpecificData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildCodexAuthFile(connectionId: string): Promise<BuiltCodexAuthFile> {
|
||||
const connection = await resolveFreshCodexConnection(connectionId);
|
||||
const payload = buildCodexAuthPayload(connection);
|
||||
const connectionLabel = getConnectionLabel(connection);
|
||||
const fileName = `codex-auth-${sanitizeFileNamePart(connectionLabel)}.json`;
|
||||
const content = JSON.stringify(payload, null, 2) + "\n";
|
||||
|
||||
return {
|
||||
connectionId,
|
||||
connectionLabel,
|
||||
fileName,
|
||||
payload,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeCodexAuthFileToLocalCli(connectionId: string) {
|
||||
const built = await buildCodexAuthFile(connectionId);
|
||||
const paths = getCliConfigPaths("codex");
|
||||
const authPath = paths?.auth;
|
||||
|
||||
if (!authPath) {
|
||||
throw new CodexAuthFileError("Codex auth path could not be resolved", 500, "path_unavailable");
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(authPath), { recursive: true });
|
||||
await createBackup("codex", authPath);
|
||||
await fs.writeFile(authPath, built.content, { encoding: "utf8", mode: 0o600 });
|
||||
|
||||
try {
|
||||
await fs.chmod(authPath, 0o600);
|
||||
} catch {
|
||||
// Best effort on platforms that ignore chmod semantics.
|
||||
}
|
||||
|
||||
return {
|
||||
...built,
|
||||
authPath,
|
||||
};
|
||||
}
|
||||
@@ -10,9 +10,13 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { getSettings } from "../db/settings";
|
||||
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
|
||||
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations";
|
||||
import { getLoggedInputTokens, getLoggedOutputTokens } from "./tokenAccounting";
|
||||
import { isNoLog } from "../compliance";
|
||||
import { sanitizePII } from "../piiSanitizer";
|
||||
import { protectPayloadForLog, parseStoredPayload } from "../logPayloads";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -33,21 +37,14 @@ function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function parseJsonString(value: unknown): unknown | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTruncatedFlag(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
return (value as Record<string, unknown>)._truncated === true;
|
||||
}
|
||||
|
||||
const CALL_LOGS_MAX = parseInt(process.env.CALL_LOGS_MAX || "200", 10);
|
||||
const DEFAULT_MAX_CALL_LOGS = 10000;
|
||||
const CALL_LOGS_MAX_CACHE_TTL_MS = 30_000;
|
||||
|
||||
const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || "7", 10);
|
||||
const CALL_LOG_PAYLOAD_MODE = (() => {
|
||||
const value = (process.env.CALL_LOG_PAYLOAD_MODE || "full").toLowerCase();
|
||||
@@ -56,79 +53,54 @@ const CALL_LOG_PAYLOAD_MODE = (() => {
|
||||
const shouldLogPayloadInDb = CALL_LOG_PAYLOAD_MODE !== "none";
|
||||
const shouldLogPayloadOnDisk = CALL_LOG_PAYLOAD_MODE === "full";
|
||||
|
||||
/** Fields that should always be redacted from logged payloads */
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"api_key",
|
||||
"apiKey",
|
||||
"api-key",
|
||||
"authorization",
|
||||
"Authorization",
|
||||
"x-api-key",
|
||||
"X-Api-Key",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"refresh_token",
|
||||
"refreshToken",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
]);
|
||||
let callLogsMaxCache = {
|
||||
value: resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_MAX_CALL_LOGS,
|
||||
expiresAt: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Redact sensitive fields from a payload before persistence.
|
||||
*/
|
||||
function redactPayload(obj: any): any {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(redactPayload);
|
||||
function resolveCallLogsMaxValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const redacted: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
redacted[key] = "[REDACTED]";
|
||||
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
|
||||
redacted[key] = "Bearer [REDACTED]";
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
redacted[key] = redactPayload(value);
|
||||
} else {
|
||||
redacted[key] = value;
|
||||
async function getMaxCallLogs(): Promise<number> {
|
||||
const now = Date.now();
|
||||
if (callLogsMaxCache.expiresAt > now) {
|
||||
return callLogsMaxCache.value;
|
||||
}
|
||||
|
||||
let value = resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_MAX_CALL_LOGS;
|
||||
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
const settings = await getSettings();
|
||||
const configured =
|
||||
resolveCallLogsMaxValue(settings.maxCallLogs) ??
|
||||
resolveCallLogsMaxValue(settings.MAX_CALL_LOGS);
|
||||
if (configured !== null) {
|
||||
value = configured;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to env/default cap when settings are unavailable.
|
||||
}
|
||||
return redacted;
|
||||
|
||||
callLogsMaxCache = {
|
||||
value,
|
||||
expiresAt: now + CALL_LOGS_MAX_CACHE_TTL_MS,
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize PII from string fields in a payload.
|
||||
* Uses lib/piiSanitizer config flags to determine if redaction is enabled.
|
||||
*/
|
||||
function sanitizePayloadPII(obj: any): any {
|
||||
if (typeof obj === "string") {
|
||||
return sanitizePII(obj).text;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sanitizePayloadPII);
|
||||
}
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const sanitized: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
sanitized[key] = sanitizePayloadPII(value);
|
||||
}
|
||||
return sanitized;
|
||||
export function invalidateCallLogsMaxCache(): void {
|
||||
callLogsMaxCache = {
|
||||
value: resolveCallLogsMaxValue(process.env.CALL_LOGS_MAX) ?? DEFAULT_MAX_CALL_LOGS,
|
||||
expiresAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply payload protection chain before persistence.
|
||||
* 1) Optional PII sanitization
|
||||
* 2) Mandatory key/token redaction
|
||||
*/
|
||||
function protectPayloadForLog(payload: any): any {
|
||||
if (!payload || !shouldLogPayloadInDb) return null;
|
||||
const piiSanitized = sanitizePayloadPII(payload);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
let logIdCounter = 0;
|
||||
function generateLogId() {
|
||||
logIdCounter++;
|
||||
@@ -145,8 +117,10 @@ export async function saveCallLog(entry: any) {
|
||||
const apiKeyId = entry.apiKeyId || null;
|
||||
const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false);
|
||||
|
||||
const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody);
|
||||
const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody);
|
||||
const protectedRequestBody =
|
||||
noLogEnabled || !shouldLogPayloadInDb ? null : protectPayloadForLog(entry.requestBody);
|
||||
const protectedResponseBody =
|
||||
noLogEnabled || !shouldLogPayloadInDb ? null : protectPayloadForLog(entry.responseBody);
|
||||
|
||||
// Resolve account name
|
||||
let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-";
|
||||
@@ -174,7 +148,7 @@ export async function saveCallLog(entry: any) {
|
||||
};
|
||||
|
||||
const logEntry = {
|
||||
id: generateLogId(),
|
||||
id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
method: entry.method || "POST",
|
||||
path: entry.path || "/v1/chat/completions",
|
||||
@@ -185,12 +159,8 @@ export async function saveCallLog(entry: any) {
|
||||
account,
|
||||
connectionId: entry.connectionId || null,
|
||||
duration: entry.duration || 0,
|
||||
tokensIn: toNumber(
|
||||
(entry.tokens?.prompt_tokens ?? entry.tokens?.input_tokens ?? 0) +
|
||||
(entry.tokens?.cache_read_input_tokens ?? entry.tokens?.cached_tokens ?? 0) +
|
||||
(entry.tokens?.cache_creation_input_tokens ?? 0)
|
||||
),
|
||||
tokensOut: toNumber(entry.tokens?.completion_tokens ?? entry.tokens?.output_tokens ?? 0),
|
||||
tokensIn: toNumber(getLoggedInputTokens(entry.tokens)),
|
||||
tokensOut: toNumber(getLoggedOutputTokens(entry.tokens)),
|
||||
requestType: entry.requestType || null,
|
||||
sourceFormat: entry.sourceFormat || null,
|
||||
targetFormat: entry.targetFormat || null,
|
||||
@@ -215,17 +185,18 @@ export async function saveCallLog(entry: any) {
|
||||
`
|
||||
).run(logEntry);
|
||||
|
||||
// 2. Trim old entries beyond CALL_LOGS_MAX
|
||||
// 2. Trim old entries beyond max
|
||||
const maxLogs = await getMaxCallLogs();
|
||||
const countRow = asRecord(db.prepare("SELECT COUNT(*) as cnt FROM call_logs").get());
|
||||
const count = toNumber(countRow.cnt);
|
||||
if (count > CALL_LOGS_MAX) {
|
||||
if (count > maxLogs) {
|
||||
db.prepare(
|
||||
`
|
||||
DELETE FROM call_logs WHERE id IN (
|
||||
SELECT id FROM call_logs ORDER BY timestamp ASC LIMIT ?
|
||||
)
|
||||
`
|
||||
).run(count - CALL_LOGS_MAX);
|
||||
).run(count - maxLogs);
|
||||
}
|
||||
|
||||
// 3. Write full payload to disk file (untruncated)
|
||||
@@ -420,8 +391,8 @@ export async function getCallLogById(id: string) {
|
||||
apiKeyId: toStringOrNull(entryRow.api_key_id),
|
||||
apiKeyName: toStringOrNull(entryRow.api_key_name),
|
||||
comboName: toStringOrNull(entryRow.combo_name),
|
||||
requestBody: parseJsonString(entryRow.request_body),
|
||||
responseBody: parseJsonString(entryRow.response_body),
|
||||
requestBody: parseStoredPayload(entryRow.request_body),
|
||||
responseBody: parseStoredPayload(entryRow.response_body),
|
||||
error: toStringOrNull(entryRow.error),
|
||||
};
|
||||
|
||||
@@ -442,7 +413,20 @@ export async function getCallLogById(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
const detailed = getRequestDetailLogByCallLogId(id);
|
||||
if (!detailed) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
pipelinePayloads: {
|
||||
clientRequest: detailed.client_request ?? null,
|
||||
providerRequest: detailed.translated_request ?? null,
|
||||
providerResponse: detailed.provider_response ?? null,
|
||||
clientResponse: detailed.client_response ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -157,13 +157,15 @@ async function getAntigravityUsage(accessToken) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Usage
|
||||
* Claude Usage (legacy fallback)
|
||||
* Real Claude OAuth quota windows are fetched in @omniroute/open-sse/services/usage.ts.
|
||||
*/
|
||||
async function getClaudeUsage(accessToken) {
|
||||
async function getClaudeUsage() {
|
||||
try {
|
||||
// Claude OAuth doesn't expose usage API directly
|
||||
// Could potentially check via inference endpoint
|
||||
return { message: "Claude connected. Usage tracked per request." };
|
||||
return {
|
||||
message:
|
||||
"Claude connected. Detailed quota windows are handled by the open-sse usage service.",
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Claude usage." };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user