Merge branch 'main' into refactor-split-ports

This commit is contained in:
Steven
2026-02-26 15:17:56 +00:00
committed by GitHub
182 changed files with 13566 additions and 4012 deletions

View File

@@ -0,0 +1,131 @@
---
description: Analyze open feature request issues, implement viable ones on dedicated branches, and respond to authors
---
# /implement-features — Feature Request Implementation Workflow
## Overview
Fetches open feature request issues, analyzes each against the current codebase, implements viable ones on dedicated branches, and responds to authors with results. Does NOT merge to main — leaves branches for author validation.
## Steps
### 1. Identify the Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract owner/repo
### 2. Fetch Open Feature Request Issues
// turbo
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 50 --json number,title,labels,body,comments,createdAt,author`
- Filter for issues that are feature requests (label `enhancement`/`feature`, or body describes new functionality, or previously classified as feature request)
- Sort by oldest first
### 3. Analyze Each Feature Request
For each feature request issue, perform a **two-level analysis**:
#### Level 1 — Viability Assessment
Ask yourself:
- Does this feature align with the project's goals and architecture?
- Is the request technically feasible with the current codebase?
- Does it duplicate existing functionality?
- Would it introduce breaking changes or security risks?
- Is there enough detail to implement it?
**Verdict options:**
1.**VIABLE** — Makes sense, enough detail to implement → Go to Level 2
2.**NEEDS MORE INFO** — Good idea but insufficient detail → Post comment asking for specifics
3.**NOT VIABLE** — Doesn't fit the project or is fundamentally flawed → Post comment explaining why, close issue
#### Level 2 — Implementation (only for VIABLE features)
1. **Research** — Read all related source files to understand the current architecture
2. **Design** — Plan the implementation, filling gaps in the original request
3. **Create branch** — Name format: `feat/issue-<NUMBER>-<short-slug>`
```bash
git checkout main
git pull origin main
git checkout -b feat/issue-<NUMBER>-<short-slug>
```
4. **Implement** — Build the complete solution following project patterns
5. **Build** — Run `npm run build` to verify compilation
6. **Commit** — Commit with: `feat: <description> (#<NUMBER>)`
7. **Push** — Push the branch: `git push -u origin feat/issue-<NUMBER>-<short-slug>`
8. **Return to main** — `git checkout main`
### 4. Respond to Authors
#### For VIABLE (implemented) features:
// turbo
Post a comment on the issue:
````markdown
## ✅ Feature Implemented!
Hi @<author>! We've analyzed your request and implemented it on a dedicated branch.
**Branch:** `feat/issue-<NUMBER>-<short-slug>`
### What was implemented:
- <bullet list of what was done>
### How to try it:
```bash
git fetch origin
git checkout feat/issue-<NUMBER>-<short-slug>
npm install && npm run dev
```
````
### Next steps:
1. **Test it** — Please verify it works as you expected
2. **Want to improve it?** — You're welcome to contribute! Just:
```bash
git checkout feat/issue-<NUMBER>-<short-slug>
# Make your improvements
git add -A && git commit -m "improve: <your changes>"
git push origin feat/issue-<NUMBER>-<short-slug>
```
Then open a Pull Request from your branch to `main` 🎉
3. **Not quite right?** — Let us know in this issue what needs to change
Looking forward to your feedback! 🚀
```
#### For NEEDS MORE INFO:
// turbo
Post a comment asking for specific missing details needed to implement, e.g.:
- "Could you describe the exact behavior when X happens?"
- "Which API endpoints should be affected?"
- "Should this apply to all providers or only specific ones?"
Add the context of WHY you need each piece of information.
#### For NOT VIABLE:
// turbo
Post a polite comment explaining why the feature doesn't fit at this time:
- If the idea is decent but timing is wrong: "This is an interesting idea, but it doesn't align with our current priorities. Feel free to open a new issue with more details if you'd like us to reconsider."
- If fundamentally flawed: Explain the technical or architectural reasons why it won't work, suggest alternatives if possible.
- Close the issue after posting the comment.
### 5. Summary Report
Present a summary report to the user via `notify_user`:
| Issue | Title | Verdict | Branch / Action |
|---|---|---|---|
| #N | Title | ✅ Implemented | `feat/issue-N-slug` |
| #N | Title | ❓ Needs Info | Comment posted |
| #N | Title | ❌ Not Viable | Closed with explanation |
```

View File

@@ -0,0 +1,100 @@
---
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, then commit and release
---
# /resolve-issues — Automated Issue Resolution Workflow
## Overview
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, resolves what can be fixed, triages issues with insufficient information, and generates a release with all fixes.
## Steps
### 1. Identify the GitHub Repository
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
- Parse the owner and repo name from the URL
### 2. Fetch All Open Issues
// turbo
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 100 --json number,title,labels,body,comments,createdAt,author`
- Parse the JSON output to get a list of all open issues
- Sort by oldest first (FIFO)
### 3. Classify Each Issue
For each issue, determine its type:
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
- **Question** — Has `question` label, or is asking "how to" something
- **Other** — Anything else
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
### 4. Analyze Each Bug — For each bug issue:
#### 4a. Check Information Sufficiency
Verify the issue contains enough information to reproduce and fix:
- [ ] Clear description of the problem
- [ ] Steps to reproduce
- [ ] Error messages or logs
- [ ] Expected vs actual behavior
#### 4b. If Information Is INSUFFICIENT
Call the `/issue-triage` workflow (located at `~/.gemini/antigravity/global_workflows/issue-triage.md`):
// turbo
- Post a comment asking for more details using `gh issue comment`
- Add `needs-info` label using `gh issue edit`
- Mark this issue as **DEFERRED** and move to the next one
#### 4c. If Information Is SUFFICIENT
Proceed with resolution:
1. **Research** — Search the codebase for files related to the issue
2. **Root Cause** — Identify the root cause by reading the relevant source files
3. **Implement Fix** — Apply the fix following existing code patterns and conventions
4. **Test** — Build the project and run tests to verify the fix
5. **Commit** — Commit with message format: `fix: <description> (#<issue_number>)`
### 5. Commit All Fixes
After processing all issues:
- Ensure all fixes are committed with proper issue references
- Each fix should be its own commit for clean git history
### 6. Close Resolved Issues
For each successfully fixed issue:
// turbo
- Close with a comment: `gh issue close <NUMBER> --repo <owner>/<repo> --comment "Fixed in <commit_hash>. The fix will be included in the next release."`
### 7. Generate Report
Present a summary report to the user via `notify_user`:
| Issue | Title | Status | Action |
| ----- | ----- | ------------- | --------------------------- |
| #N | Title | ✅ Fixed | Commit hash |
| #N | Title | ❓ Needs Info | Triage comment posted |
| #N | Title | ⏭️ Skipped | Feature request / not a bug |
### 8. Update Docs & Release
If any fixes were committed:
1. Run the `/update-docs` workflow (at `~/.gemini/antigravity/global_workflows/update-docs.md`) to update CHANGELOG and README
2. Run the `/generate-release` workflow (at `.agents/workflows/generate-release.md`) to bump version, tag, and publish
If NO fixes were committed, skip this step and just present the report.

View File

@@ -0,0 +1,96 @@
---
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
---
# /review-prs — PR Review & Analysis Workflow
## Overview
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation.
## Steps
### 1. Identify the GitHub Repository
- Read `package.json` to get the repository URL, or use the git remote origin URL
// turbo
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
### 2. Fetch Open Pull Requests
- Navigate to `https://github.com/<owner>/<repo>/pulls` and scrape all open PRs
- For each open PR, collect:
- PR number, title, author, branch, number of commits, date
- PR description/body
- Files changed (diff)
- Existing review comments (from bots or humans)
### 3. Analyze Each PR — For each open PR, perform the following analysis:
#### 3a. Feature Assessment
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
- **Alignment** — Check if it aligns with the project's architecture and roadmap
- **Complexity** — Assess if the scope is reasonable or if it should be split
#### 3b. Code Quality Review
- Check for code duplication
- Evaluate error handling patterns (consistent with existing codebase?)
- Check naming conventions and code style
- Verify TypeScript types (any `any` usage, missing types?)
#### 3c. Security Review
- Check for missing authentication/authorization on new endpoints
- Check for injection vulnerabilities (URL params, SQL, XSS)
- Verify input validation on all user-controlled data
- Check for hardcoded secrets or credentials
#### 3d. Architecture Review
- Does the change follow existing patterns?
- Are there any breaking changes to public APIs?
- Is the database schema affected? Migration needed?
- Impact on performance (N+1 queries, missing indexes?)
#### 3e. Test Coverage
- Does the PR include tests?
- Are edge cases covered?
- Would existing tests break?
### 4. Generate Report — Create a markdown report for each PR including:
- **PR Summary** — What it does, files affected, commit count
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
- **Verdict** — Ready to merge? With mandatory vs optional fixes
- **Next Steps** — What will happen if approved
### 5. Present to User
- Show the report via `notify_user` with `BlockedOnUser: true`
- Wait for user decision:
- **Approved** → Proceed to step 6
- **Approved with changes** → Implement the fixes and corrections before merging
- **Rejected** → Close the PR or leave a review comment
### 6. Implementation (if approved)
- Checkout the PR branch or apply changes locally
- Implement any required fixes identified in the analysis
- Run the project's test suite to verify nothing breaks
// turbo
- Run: `npm test` or equivalent test command
- Build the project to verify compilation
// turbo
- Run: `npm run build` or equivalent build command
- If all checks pass, prepare the merge
### 7. Post-Merge (if applicable)
- Update CHANGELOG.md with the new feature
- Consider version bump if warranted
- Follow the `/generate-release` workflow if a release is needed

View File

@@ -1,22 +0,0 @@
name: Codex PR Review
on:
pull_request:
types: [opened, synchronize]
jobs:
request-codex-review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Request Codex Review
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '@codex review'
});

38
.github/workflows/deploy-vps.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Deploy to VPS
on:
workflow_run:
workflows: ["Publish to Docker Hub"]
types: [completed]
workflow_dispatch:
jobs:
deploy:
if: >-
(github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success')
&& vars.DEPLOY_ENABLED == 'true'
name: Deploy OmniRoute to VPS
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
timeout: 30s
script: |
echo "=== Updating OmniRoute ==="
npm install -g omniroute@latest 2>&1
INSTALLED_VERSION=$(omniroute --version 2>/dev/null || echo "unknown")
echo "Installed version: $INSTALLED_VERSION"
echo "=== Restarting PM2 ==="
pm2 restart omniroute || pm2 start omniroute --name omniroute -- --port 20128
pm2 save
echo "=== Health Check ==="
sleep 3
curl -sf http://localhost:20128/api/settings > /dev/null && echo "✅ OmniRoute is healthy" || echo "❌ Health check failed"
echo "=== Deploy complete ==="

View File

@@ -8,9 +8,65 @@ permissions:
contents: read
jobs:
docker:
name: Build & Push Docker Image
build:
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
platform_pair: linux-amd64
runner: ubuntu-latest
- platform: linux/arm64
platform_pair: linux-arm64
runner: ubuntu-24.04-arm
env:
IMAGE_NAME: diegosouzapw/omniroute
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
target: runner-base
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=omniroute-runner-base-${{ matrix.platform_pair }}
cache-to: type=gha,mode=max,scope=omniroute-runner-base-${{ matrix.platform_pair }}
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform_pair }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge manifest and publish tags
runs-on: ubuntu-latest
needs: build
env:
IMAGE_NAME: diegosouzapw/omniroute
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -23,8 +79,12 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing Docker image version: $VERSION"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
@@ -32,18 +92,20 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: runner-base
push: true
tags: |
diegosouzapw/omniroute:${{ steps.version.outputs.version }}
diegosouzapw/omniroute:latest
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
-t "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}" \
-t "${{ env.IMAGE_NAME }}:latest" \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v5

2
.gitignore vendored
View File

@@ -95,3 +95,5 @@ security-analysis/
# Deploy workflow (contains sensitive VPS credentials)
.agent/workflows/deploy.md
clipr/
app.log
*.tgz

View File

@@ -7,6 +7,258 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.4.11] — 2026-02-25
> ### 🐛 Settings Persistence Fix
>
> Fixes routing strategy and wildcard aliases not saving after page refresh.
### 🐛 Bug Fixes
- **Routing Strategy Not Saved After Refresh (#134)** — Added `fallbackStrategy`, `wildcardAliases`, and `stickyRoundRobinLimit` to the Zod validation schema. These fields were silently stripped during validation, preventing them from being persisted to the database
### 📝 Notes
- **#135 Closed** — Feature request for proxy configuration (global + per-provider) was already implemented in v1.4.10
---
## [1.4.10] — 2026-02-25
> ### 🔒 Proxy Visibility + Bug Fixes
>
> Color-coded proxy badges, provider-level proxy configuration, CLI tools page fix, and EACCES fix for restricted environments.
### ✨ New Features
- **Color-Coded Proxy Badges** — Each provider connection now shows its proxy status with color-coded badges: 🟢 green (global proxy), 🟡 amber (provider-level proxy), 🔵 blue (per-connection proxy). Badge always displays the proxy IP/host
- **Provider-Level Proxy Button** — New "Provider Proxy" button in the Connections header of each provider detail page. Allows configuring a proxy that applies to all connections of that provider
- **Proxy IP Display** — The proxy badge now always shows the proxy host/IP address for quick identification
### 🐛 Bug Fixes
- **CLI Tools Page Stuck in Loading** — Fixed the `/api/cli-tools/status` endpoint hanging indefinitely when binary checks stall on VPS. Added 5s server-side timeout per tool and 8s client-side AbortController timeout (#cli-tools-hang)
- **EACCES on Restricted Home Directories** — Fixed crash when `~/.omniroute` directory cannot be created due to permission issues. Now gracefully warns and suggests using the `DATA_DIR` environment variable (#133)
---
> ### 🌐 Full Internationalization (i18n) + Multi-Account Fix
>
> Complete dashboard i18n migration with next-intl, supporting English and Portuguese (Brazil). Fixes production build issues and enables multiple Codex accounts from the same workspace.
### ✨ New Features
- **Full Dashboard Internationalization** — Complete i18n migration of 21+ pages and components using `next-intl`. Every dashboard string is now translatable with full EN and PT-BR support. Includes language selector (globe icon) in the header for real-time language switching
- **Portuguese (Brazil) Translation** — Complete `pt-BR.json` translation file with 500+ keys covering all pages: Home, Providers, Settings, Combos, Analytics, Costs, Logs, Health, CLI Tools, Endpoint, API Manager, and Onboarding
- **Language Selector Component** — New `LanguageSelector` component in the header with flag icons and dropdown for switching between 🇺🇸 English and 🇧🇷 Português
### 🐛 Bug Fixes
- **Multiple Codex Accounts from Same Workspace** — Fixed deduplication logic in `createProviderConnection` that prevented adding multiple OpenAI Pro Business accounts from the same Team workspace. Now uses compound check (workspaceId + email) instead of workspaceId-only, allowing separate connections per user
- **Production Build — Crypto Import** — Fixed `instrumentation.ts` using `eval('require')('crypto')` to bypass webpack's static analysis that blocked the Node.js crypto module in the bundled instrumentation file
- **Production Build — Translation Scope** — Fixed sub-components `ProviderOverviewCard` and `ProviderModelsModal` in `HomePageClient.tsx` that referenced parent-scope translation hooks. Each sub-component now has its own `useTranslations()` call
- **Production Build — app/ Directory Conflict** — Resolved Next.js 16 confusing the production `app/` directory (server build output) with the `src/app/` app router directory, which caused "missing root layout" build failures
### 📄 i18n Pages Migrated
Home, Endpoint, API Manager, Providers (list + detail + new), Combos, Logs, Costs, Analytics, Health, CLI Tools, Settings (General, Security, Routing, Session, IP Filter, Compliance, Fallback Chains, Thinking Budget, Policies, Pricing, Resilience, Advanced), Onboarding Wizard, Audit Log, Usage
---
## [1.4.7] — 2026-02-25
> ### 🐛 Bugfix — Antigravity Model Prefix & Version Sync
>
> Fixes model name sent to Antigravity upstream API containing `antigravity/` prefix, causing 400 errors for non-opus models. Also syncs package-lock.json version.
### 🐛 Bug Fixes
- **Antigravity Model Prefix Stripping** — Model names sent to the Antigravity upstream API (Google Cloud Code) now have any `provider/` prefix defensively stripped. Previously, models like `antigravity/gemini-3-flash` were sent with the prefix intact, causing 400 errors from the upstream API. Only `claude-opus-4-6-thinking` worked because its routing path differed. Fix applied in 3 locations: `antigravity.ts` executor, and both `wrapInCloudCodeEnvelope` and `wrapInCloudCodeEnvelopeForClaude` in the translator
- **Package-lock.json Version Sync** — Fixed `package-lock.json` being stuck at `1.4.3` while `package.json` was at `1.4.6`, which prevented npm from publishing the correct version and caused the VPS deploy to stay on the old version
---
## [1.4.6] — 2026-02-25
> ### ✨ Community Release — Security Fix, Multi-Platform Docker, Model Updates & Plus Tier
>
> Enforces API key model restrictions across all endpoints, adds ARM64 Docker support, updates model registry for latest AI models, and introduces Plus tier in ProviderLimits.
### 🔒 Security
- **API Key Model Restrictions Enforced** — `isModelAllowedForKey()` was never called, allowing API keys with `allowedModels` restrictions to access any model. Created centralized `enforceApiKeyPolicy()` middleware and wired it into all `/v1/*` endpoints (chat, embeddings, images, audio, moderations, rerank). Supports exact match, prefix match (`openai/*`), and wildcard patterns ([#130](https://github.com/diegosouzapw/OmniRoute/issues/130), [PR #131](https://github.com/diegosouzapw/OmniRoute/pull/131) by [@ersintarhan](https://github.com/ersintarhan))
- **ApiKeyMetadata Type Safety** — Replaced `any` types with proper `ApiKeyMetadata` interface in the policy middleware. Added error logging in catch blocks for metadata fetch and budget check failures
### ✨ New Features
- **Docker Multi-Platform Builds** — Restructured Docker CI workflow to support both `linux/amd64` and `linux/arm64` using native runners and digest-based manifest merging. ARM64 users (Apple Silicon, AWS Graviton, Raspberry Pi) can now run OmniRoute natively ([PR #127](https://github.com/diegosouzapw/OmniRoute/pull/127) by [@npmSteven](https://github.com/npmSteven))
- **Plus Tier in ProviderLimits** — Added "Plus" as a separate category in the ProviderLimits dashboard, distinguishing Plus/Paid plans from Pro plans with proper ranking and filtering ([PR #126](https://github.com/diegosouzapw/OmniRoute/pull/126) by [@nyatoru](https://github.com/nyatoru))
### 🔧 Improvements
- **Model Registry Updates** — Updated provider registry, usage tracking, CLI tools config, and pricing for latest AI models: added Claude Sonnet 4.6, Gemini 3.1 Pro (High/Low), GPT OSS 120B Medium; removed deprecated Claude 4.5 variants and Gemini 2.5 Flash ([PR #128](https://github.com/diegosouzapw/OmniRoute/pull/128) by [@nyatoru](https://github.com/nyatoru))
- **Model ID Consistency** — Fixed `claude-sonnet-4-6-thinking``claude-sonnet-4-6` mismatch in `importantModels` to match the provider registry
---
## [1.4.5] — 2026-02-24
> ### 🐛 Bugfix Release — Claude Code OAuth & OAuth Proxy Routing
>
> Fixes Claude Code OAuth failures on remote deployments and routes all OAuth token exchanges through configured proxy.
### 🐛 Bug Fixes
- **Claude Code OAuth** — Fixed `400 Bad Request` on remote deployments by using Anthropic's registered `redirect_uri` (`https://platform.claude.com/oauth/code/callback`) instead of the dynamic server URL. Added missing OAuth scopes (`user:sessions:claude_code`, `user:mcp_servers`) to match the official Claude CLI. Configurable via `CLAUDE_CODE_REDIRECT_URI` env var ([#124](https://github.com/diegosouzapw/OmniRoute/issues/124))
- **OAuth Token Exchange Through Proxy** — OAuth token exchange during new connection setup now routes through the configured proxy (provider-level → global → direct), fixing `unsupported_country_region_territory` errors for region-restricted providers like OpenAI Codex ([#119](https://github.com/diegosouzapw/OmniRoute/issues/119))
---
## [1.4.4] — 2026-02-24
> ### ✨ Feature Release — Custom Provider Models in /v1/models
>
> Compatible provider models are now saved to the customModels database, making them visible via `/v1/models` for all OpenAI-compatible clients.
### ✨ New Features
- **Custom Provider Model Persistence** — Compatible provider models (manual or imported) are now saved to the `customModels` database so they appear in `/v1/models` listing for clients like Cursor, Cline, Antigravity, and Claude Code ([PR #122](https://github.com/diegosouzapw/OmniRoute/pull/122) by [@nyatoru](https://github.com/nyatoru))
- **Provider Models API** — New `/api/provider-models` endpoint (GET/POST/DELETE) for managing custom model entries with full authentication via `isAuthenticated`
- **Unified Model Deletion** — New `handleDeleteModel` removes models from both alias configuration and `customModels` database, preventing orphaned entries
- **Provider Node Prefix Resolution** — `getModelInfo` refactored to use provider node prefixes for accurate custom provider model resolution
### 🔒 Security
- **Authentication on Provider Models API** — All `/api/provider-models` endpoints require API key or JWT session authentication via shared `isAuthenticated` utility
- **URL Parameter Injection Fix** — Applied `encodeURIComponent` to all user-controlled URL parameters (`providerStorageAlias`, `providerId`) to prevent query string injection attacks
- **Shared Auth Utility** — Authentication logic extracted to `@/shared/utils/apiAuth.ts`, eliminating code duplication across `/api/models/alias` and `/api/provider-models`
### 🔧 Improvements
- **Toast Notifications** — Replaced blocking `alert()` calls with non-blocking `notify.error`/`notify.success` toast notifications matching the project's notification system
- **Transactional Save** — Model persistence is now transactional: database save must succeed before alias creation, preventing inconsistent state
- **Consistent Error Handling** — All model operations (add, import, delete) now provide user-facing error/success feedback via toast notifications
- **ComboFormModal Matching** — Improved provider node matching by ID or prefix for combo model selection
---
## [1.4.3] — 2026-02-23
### 🐛 Bug Fix
- **OAuth LAN Access** — Fixed OAuth flow for remote/LAN IP access (`192.168.x.x`). Previously, LAN IPs incorrectly used popup mode, leading to a broken redirect loop. Now defaults to manual callback URL input mode for non-localhost access
---
## [1.4.2] — 2026-02-23
### 🐛 Bug Fix
- **OAuth Token Refresh** — Fixed `client_secret is missing` error for Google-based OAuth providers (Antigravity, Gemini, Gemini CLI, iFlow). Desktop/CLI OAuth secrets are now hardcoded as defaults since Next.js inlined empty strings at build time.
---
## [1.4.1] — 2026-02-23
### 🔧 Improvements
- **Endpoint Page Cleanup** — Removed redundant API Key Management section from Endpoint page (now fully managed in the dedicated API Manager page)
- **CI/CD** — Added `deploy-vps.yml` workflow for automatic VPS deployment on new releases
---
## [1.4.0] — 2026-02-23
> ### ✨ Feature Release — Dedicated API Key Manager with Model Permissions
>
> Community-contributed API Key Manager page with model-level access control, enhanced with usage statistics, key status indicators, and improved UX.
### ✨ New Features
- **Dedicated API Key Manager** — New `/dashboard/api-manager` page for managing API keys, extracted from the Endpoint page. Includes create, delete, and permissions management with a clean table UI ([PR #118](https://github.com/diegosouzapw/OmniRoute/pull/118) by [@nyatoru](https://github.com/nyatoru))
- **Model-Level API Key Permissions** — Restrict API keys to specific models using `allowed_models` with wildcard pattern support (e.g., `openai/*`). Toggle between "Allow All" and "Restrict" modes with an intuitive provider-grouped model selector
- **API Key Validation Cache** — 3-tier caching layer (validation, metadata, permission) reduces database hits on every request, with automatic cache invalidation on key changes
- **Usage Statistics Per Key** — Each API key shows total request count and last used timestamp, with a stats summary dashboard (total keys, restricted keys, total requests, models available)
- **Key Status Indicators** — Color-coded lock/unlock icons and copy buttons on each key row for quick identification of restricted vs unrestricted keys
### 🔧 Improvements
- **Endpoint Page Simplified** — API key management removed from Endpoint page and replaced with a prominent link to the API Manager
- **Sidebar Navigation** — New "API Manager" entry with `vpn_key` icon in the sidebar
- **Prepared Statements** — API key database operations now use cached prepared statements for better performance
- **Input Validation** — XSS-safe sanitization and regex validation for key names; ID format validation for API calls
---
## [1.3.1] — 2026-02-23
> ### 🐛 Bugfix Release — Proxy Connection Tests & Compatible Provider Display
>
> Fixes provider connection tests bypassing configured proxy and improves compatible provider display in the request logger.
### 🐛 Bug Fixes
- **Connection Tests Now Use Proxy** — Provider connection tests (`Test Connection` button) now route through the configured proxy (key → combo → provider → global → direct), matching the behavior of real API calls. Previously, `fetch()` was called directly, bypassing the proxy entirely ([#119](https://github.com/diegosouzapw/OmniRoute/issues/119))
- **Compatible Provider Display in Logs** — OpenAI/Anthropic compatible providers now show friendly labels (`OAI-COMPAT`, `ANT-COMPAT`) instead of raw UUID-based IDs in the request logger's provider column, dropdown, and quick filters ([#113](https://github.com/diegosouzapw/OmniRoute/issues/113))
### 🧪 Tests
- **Connection Test Unit Tests** — 26 new test cases covering error classification logic, token expiry detection, and provider display label resolution
---
## [1.3.0] — 2026-02-23
> ### ✨ Feature Release — iFlow Fix, Health Check Logs Toggle, Kilocode Models & Model Deduplication
>
> Community-driven release with iFlow HMAC-SHA256 signature support, health check log management, expanded Kilocode model list, and model deduplication on the dashboard.
### ✨ New Features
- **Hide Health Check Logs** — New toggle in Settings → Appearance to suppress verbose `[HealthCheck]` messages from the server console. Uses a 30-second cache to minimize database reads with request coalescing for concurrent calls ([PR #111](https://github.com/diegosouzapw/OmniRoute/pull/111) by [@nyatoru](https://github.com/nyatoru))
- **Kilocode Custom Models Endpoint** — Added `modelsUrl` support in `RegistryEntry` for providers with non-standard model endpoints. Expanded Kilocode model list from 8 to 26 models including Qwen3, GPT-5, Claude 3 Haiku, Gemini 2.5, DeepSeek V3, Llama 4, and more ([PR #115](https://github.com/diegosouzapw/OmniRoute/pull/115) by [@benzntech](https://github.com/benzntech))
### 🐛 Bug Fixes
- **iFlow 406 Error** — Created dedicated `IFlowExecutor` with HMAC-SHA256 signature support (`session-id`, `x-iflow-timestamp`, `x-iflow-signature` headers). The iFlow provider was previously using the default executor which lacked the required signature headers, causing 406 errors ([#114](https://github.com/diegosouzapw/OmniRoute/issues/114))
- **Duplicate Models in Endpoint Lists** — Filtered out parent models (`!m.parent`) from all model categorization and count logic on the Endpoint page. Provider modal lists also exclude duplicates ([PR #112](https://github.com/diegosouzapw/OmniRoute/pull/112) by [@nyatoru](https://github.com/nyatoru))
### 🧪 Tests
- **IFlowExecutor Unit Tests** — 11 new test cases covering HMAC-SHA256 signature generation, header building, URL construction, body passthrough, and executor registry integration
---
## [1.2.0] — 2026-02-22
> ### ✨ Feature Release — Dashboard Session Auth for Models Endpoint
>
> Dashboard users can now access `/v1/models` via their existing session when API key auth is required.
### ✨ New Features
- **JWT Session Auth Fallback** — When `requireAuthForModels` is enabled, the `/v1/models` endpoint now accepts both API key (Bearer token) for external clients **and** the dashboard JWT session cookie (`auth_token`), allowing logged-in dashboard users to view models without needing an explicit API key ([PR #110](https://github.com/diegosouzapw/OmniRoute/pull/110) by [@nyatoru](https://github.com/nyatoru))
### 🔧 Improvements
- **401 instead of 404** — Authentication failures on `/v1/models` now return `401 Unauthorized` with a structured JSON error body (OpenAI-compatible format) instead of a generic `404 Not Found`, improving debuggability for API clients
- **Simplified auth logic** — Refactored the JWT cookie verification to reuse the same pattern as `apiAuth.ts`, removing redundant same-origin detection (~60 lines) since the `sameSite:lax` + `httpOnly` cookie flags already provide equivalent CSRF protection
---
## [1.1.1] — 2026-02-22
> ### 🐛 Bugfix Release — API Key Creation & Codex Team Plan Quotas
>
> Fixes API key creation crash when `API_KEY_SECRET` is not set and adds Code Review rate limit window to Codex quota display.
### 🐛 Bug Fixes
- **API Key Creation** — Added deterministic fallback for `API_KEY_SECRET` to prevent `crypto.createHmac` crash when the environment variable is not configured. Keys created without the secret are insecure (warned at startup) but the application no longer crashes ([#108](https://github.com/diegosouzapw/OmniRoute/issues/108))
- **Codex Code Review Quota** — Added parsing of the third rate limit window (`code_review_rate_limit`) from the ChatGPT usage API, supporting Plus/Pro/Team plan differences. The dashboard now displays all three quota bars: Session (5h), Weekly, and Code Review ([#106](https://github.com/diegosouzapw/OmniRoute/issues/106))
---
## [1.1.0] — 2026-02-21
> ### 🐛 Bugfix Release — OAuth Client Secret and Codex Business Quotas
@@ -437,6 +689,18 @@ New environment variables:
---
[1.4.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.7
[1.4.6]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.6
[1.4.5]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.5
[1.4.4]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.4
[1.4.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.3
[1.4.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.2
[1.4.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.1
[1.4.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.4.0
[1.3.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.3.1
[1.3.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.3.0
[1.2.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.2.0
[1.1.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.1
[1.0.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.7
[1.0.6]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.6
[1.0.5]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.5

125
README.md
View File

@@ -3,6 +3,8 @@
# 🚀 OmniRoute — The Free AI Gateway
🌐 **[English](#-omniroute--the-free-ai-gateway)** | **[Português (BR)](#-omniroute--gateway-de-ia-gratuito)**
### Never stop coding. Smart routing to **FREE & low-cost AI models** with automatic fallback.
_Your universal API proxy — one endpoint, 36+ providers, zero downtime._
@@ -374,16 +376,18 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
### 🛡️ Resilience & Security
| Feature | What It Does |
| ------------------------------- | ------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Auto-open/close per-provider with configurable thresholds |
| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore rate-limit for API key providers |
| 🧠 **Semantic Cache** | Two-tier cache (signature + semantic) reduces cost & latency |
| ⚡ **Request Idempotency** | 5s dedup window for duplicate requests |
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📊 **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level |
| 🛡 **API Endpoint Protection** | Auth gating + provider blocking for the `/models` endpoint |
| Feature | What It Does |
| ------------------------------- | ----------------------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Auto-open/close per-provider with configurable thresholds |
| 🛡️ **Anti-Thundering Herd** | Mutex + semaphore rate-limit for API key providers |
| 🧠 **Semantic Cache** | Two-tier cache (signature + semantic) reduces cost & latency |
| ⚡ **Request Idempotency** | 5s dedup window for duplicate requests |
| 🔒 **TLS Fingerprint Spoofing** | Bypass TLS-based bot detection via wreq-js |
| 🌐 **IP Filtering** | Allowlist/blocklist for API access control |
| 📊 **Editable Rate Limits** | Configurable RPM, min gap, and max concurrent at system level |
| 🛡 **API Endpoint Protection** | Auth gating + provider blocking for the `/models` endpoint |
| 🔒 **Proxy Visibility** | Color-coded badges: 🟢 global, 🟡 provider, 🔵 per-connection with IP display |
| 🌐 **3-Level Proxy Config** | Configure proxies at global, per-provider, or per-connection level |
### 📊 Observability & Analytics
@@ -403,14 +407,17 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
### ☁️ Deployment & Sync
| Feature | What It Does |
| -------------------------- | --------------------------------------------------------------------- |
| 💾 **Cloud Sync** | Sync config across devices via Cloudflare Workers |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers |
| 🔑 **API Key Management** | Generate, rotate, and scope API keys per provider |
| 🧙 **Onboarding Wizard** | 4-step guided setup for first-time users |
| 🔧 **CLI Tools Dashboard** | One-click configure Claude, Codex, Cline, OpenClaw, Kilo, Antigravity |
| 🔄 **DB Backups** | Automatic backup, restore, export & import for all settings |
| Feature | What It Does |
| ---------------------------- | --------------------------------------------------------------------- |
| 💾 **Cloud Sync** | Sync config across devices via Cloudflare Workers |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers |
| 🔑 **API Key Management** | Generate, rotate, and scope API keys per provider |
| 🧙 **Onboarding Wizard** | 4-step guided setup for first-time users |
| 🔧 **CLI Tools Dashboard** | One-click configure Claude, Codex, Cline, OpenClaw, Kilo, Antigravity |
| 🔄 **DB Backups** | Automatic backup, restore, export & import for all settings |
| 🌐 **Internationalization** | Full i18n with next-intl — English + Portuguese (Brazil) support |
| 🌍 **Language Selector** | Globe icon in header for real-time language switching (🇺🇸/🇧🇷) |
| 📂 **Custom Data Directory** | `DATA_DIR` env var to override default `~/.omniroute` storage path |
<details>
<summary><b>📖 Feature Details</b></summary>
@@ -1022,7 +1029,7 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
## 🛠️ Tech Stack
- **Runtime**: Node.js 20+
- **Runtime**: Node.js 1822 LTS (⚠️ Node.js 24+ is **not supported**`better-sqlite3` native binaries are incompatible)
- **Language**: TypeScript 5.9 — **100% TypeScript** across `src/` and `open-sse/` (v1.0.6)
- **Framework**: Next.js 16 + React 19 + Tailwind CSS 4
- **Database**: LowDB (JSON) + SQLite (domain state + proxy logs)
@@ -1158,8 +1165,88 @@ MIT License - see [LICENSE](LICENSE) for details.
---
---
## 🇧🇷 OmniRoute — Gateway de IA Gratuito
<a name="-omniroute--gateway-de-ia-gratuito"></a>
### Nunca pare de codar. Roteamento inteligente para **modelos de IA GRATUITOS e de baixo custo** com fallback automático.
_Seu proxy universal de API — um endpoint, 36+ provedores, zero downtime._
### 🌐 Internacionalização (i18n)
O dashboard do OmniRoute suporta **múltiplos idiomas**. Atualmente disponível em:
| Idioma | Código | Status |
| --------------------- | ------- | ----------- |
| 🇺🇸 English | `en` | ✅ Completo |
| 🇧🇷 Português (Brasil) | `pt-BR` | ✅ Completo |
**Para trocar o idioma:** Clique no seletor de idioma (🇺🇸 EN) no header do dashboard → selecione o idioma desejado.
**Para adicionar um novo idioma:**
1. Crie `src/i18n/messages/{codigo}.json` baseado em `en.json`
2. Adicione o código em `src/i18n/config.ts``LOCALES` e `LANGUAGES`
3. Reinicie o servidor
### ⚡ Início Rápido
```bash
# Instalar via npm
npx omniroute@latest
# Ou rodar do código-fonte
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
### 🐳 Docker
```bash
docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute:latest
```
### 🔑 Funcionalidades Principais
- **36+ provedores de IA** — Claude, GPT, Gemini, Llama, Qwen, DeepSeek, e mais
- **Roteamento inteligente** — Fallback automático entre provedores
- **Tradução de formato** — OpenAI ↔ Claude ↔ Gemini automaticamente
- **Multi-conta** — Múltiplas contas por provedor com seleção inteligente
- **Cache semântico** — Reduz custos e latência
- **OAuth automático** — Tokens renovam automaticamente
- **Combos personalizados** — 6 estratégias de roteamento
- **Dashboard completo** — Monitoramento, logs, análises, configurações
- **CLI Tools** — Configure Claude Code, Codex, Cursor, Cline com um clique
- **100% TypeScript** — Código limpo e tipado
### 📖 Documentação
| Documento | Descrição |
| ----------------------------------------------- | -------------------------------------- |
| [Guia do Usuário](docs/USER_GUIDE.md) | Provedores, combos, CLI, deploy |
| [Referência da API](docs/API_REFERENCE.md) | Todos os endpoints com exemplos |
| [Solução de Problemas](docs/TROUBLESHOOTING.md) | Problemas comuns e soluções |
| [Arquitetura](docs/ARCHITECTURE.md) | Arquitetura e internos do sistema |
| [Contribuição](CONTRIBUTING.md) | Setup de desenvolvimento e guidelines |
| [Deploy em VM](docs/VM_DEPLOYMENT_GUIDE.md) | Guia completo: VM + nginx + Cloudflare |
### 📧 Suporte
> 💬 **Entre para a comunidade!** [Grupo WhatsApp](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) — Tire dúvidas, compartilhe dicas e fique atualizado.
- **Website**: [omniroute.online](https://omniroute.online)
- **GitHub**: [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute)
- **Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues)
---
<div align="center">
<sub>Built with ❤️ for developers who code 24/7</sub>
<br/>
<sub><a href="https://omniroute.online">omniroute.online</a></sub>
</div>
<!-- GitHub Discussions enabled for community Q&A -->

View File

@@ -364,16 +364,18 @@ Acesso via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
### 🛡️ Resiliência e Segurança
| Funcionalidade | O que Faz |
| ---------------------------------- | --------------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Auto-abertura/fechamento por provedor com limites configuráveis |
| 🛡️ **Anti-Thundering Herd** | Mutex + semáforo rate-limit para provedores com API key |
| 🧠 **Cache Semântico** | Cache de duas camadas (assinatura + semântico) reduz custo e latência |
| ⚡ **Idempotência de Requisição** | Janela de dedup de 5s para requisições duplicadas |
| 🔒 **Spoofing de Fingerprint TLS** | Bypass de detecção de bot via TLS com wreq-js |
| 🌐 **Filtragem de IP** | Allowlist/blocklist para controle de acesso à API |
| 📊 **Rate Limits Editáveis** | RPM, gap mínimo e concorrência máxima configuráveis |
| 🛡 **Proteção de Endpoint API** | Gateway de Auth + bloqueio de provedores para o endpoint `/models` |
| Funcionalidade | O que Faz |
| ---------------------------------- | --------------------------------------------------------------------------- |
| 🔌 **Circuit Breaker** | Auto-abertura/fechamento por provedor com limites configuráveis |
| 🛡️ **Anti-Thundering Herd** | Mutex + semáforo rate-limit para provedores com API key |
| 🧠 **Cache Semântico** | Cache de duas camadas (assinatura + semântico) reduz custo e latência |
| ⚡ **Idempotência de Requisição** | Janela de dedup de 5s para requisições duplicadas |
| 🔒 **Spoofing de Fingerprint TLS** | Bypass de detecção de bot via TLS com wreq-js |
| 🌐 **Filtragem de IP** | Allowlist/blocklist para controle de acesso à API |
| 📊 **Rate Limits Editáveis** | RPM, gap mínimo e concorrência máxima configuráveis |
| 🛡 **Proteção de Endpoint API** | Gateway de Auth + bloqueio de provedores para o endpoint `/models` |
| 🔒 **Visibilidade de Proxy** | Badges coloridos: 🟢 global, 🟡 provedor, 🔵 por-conexão com exibição de IP |
| 🌐 **Proxy em 3 Níveis** | Configure proxies em nível global, por provedor ou por conexão |
### 📊 Observabilidade e Analytics
@@ -399,6 +401,9 @@ Acesso via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
| 🧙 **Assistente de Configuração** | Setup guiado em 4 etapas para novos usuários |
| 🔧 **Dashboard CLI Tools** | Configuração em um clique para Claude, Codex, Cline, OpenClaw, Kilo, Antigravity |
| 🔄 **Backups de DB** | Backup, restauração, exportação e importação automática de todas as configurações |
| 🌐 **Internacionalização** | i18n completo com next-intl — suporte English + Português (Brasil) |
| 🌍 **Seletor de Idioma** | Ícone de globo no cabeçalho para troca de idioma em tempo real (🇺🇸/🇧🇷) |
| 📂 **Diretório de Dados Custom** | Variável `DATA_DIR` para sobrescrever o caminho padrão `~/.omniroute` |
<details>
<summary><b>📖 Detalhes das Funcionalidades</b></summary>

View File

@@ -90,6 +90,20 @@ console.log(`
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
\x1b[0m`);
// ── Node.js version check ──────────────────────────────────
const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
if (nodeMajor >= 24) {
console.warn(`\x1b[33m ⚠ Warning: You are running Node.js ${process.versions.node}.
OmniRoute uses better-sqlite3, a native addon that does not yet
have compatible prebuilt binaries for Node.js 24+.
You may experience errors like "is not a valid Win32 application"
or "NODE_MODULE_VERSION mismatch".
Recommended: use Node.js 22 LTS (or 20 LTS).
Workaround: npm rebuild better-sqlite3\x1b[0m
`);
}
// ── Resolve server entry ───────────────────────────────────
const serverJs = join(APP_DIR, "server.js");

View File

@@ -11,6 +11,8 @@ Common problems and solutions for OmniRoute.
| First login not working | Check `INITIAL_PASSWORD` in `.env` (default: `123456`) |
| Dashboard opens on wrong port | Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128` |
| No request logs under `logs/` | Set `ENABLE_REQUEST_LOGS=true` |
| EACCES: permission denied | Set `DATA_DIR=/path/to/writable/dir` to override `~/.omniroute` |
| Routing strategy not saving | Update to v1.4.11+ (Zod schema fix for settings persistence) |
---

View File

@@ -0,0 +1,36 @@
# Task 01 — Home Page (Dashboard)
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `home`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/page.tsx` | 17 | 0 (wrapper) |
| `src/app/(dashboard)/dashboard/HomePageClient.tsx` | 500 | ~25 |
## Strings a Traduzir
### HomePageClient.tsx
| Linha | String EN | Chave i18n | String PT-BR |
|-------|-----------|------------|--------------|
| 138 | "Quick Start" | `home.quickStart` | "Início Rápido" |
| 242 | "Providers Overview" | `home.providersOverview` | "Visão Geral dos Provedores" |
| 436 | "No models available for this provider." | `home.noModelsAvailable` | "Nenhum modelo disponível para este provedor." |
| — | "Total Requests" | `home.totalRequests` | "Total de Requisições" |
| — | "Active Providers" | `home.activeProviders` | "Provedores Ativos" |
| — | "Success Rate" | `home.successRate` | "Taxa de Sucesso" |
| — | "Avg Latency" | `home.avgLatency` | "Latência Média" |
| — | "Configure Endpoint" | `home.configureEndpoint` | "Configurar Endpoint" |
| — | "Add Provider" | `home.addProvider` | "Adicionar Provedor" |
| — | "View Docs" | `home.viewDocs` | "Ver Documentação" |
| — | "Copied!" | `common.copied` | ✅ já existe |
| — | "requests" | `home.requests` | "requisições" |
| — | "models" | `home.models` | "modelos" |
| — | "accounts" | `home.accounts` | "contas" |
## Checklist
- [ ] Adicionar chaves no `en.json` (namespace `home`)
- [ ] Adicionar traduções no `pt-BR.json`
- [ ] Substituir strings por `t()` no `HomePageClient.tsx`
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,25 @@
# Task 02 — Analytics Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `analytics`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/analytics/page.tsx` | 46 | ~8 |
## Strings a Traduzir
| Linha | String EN | Chave i18n | String PT-BR |
|-------|-----------|------------|--------------|
| 11 | "Monitor your API usage patterns..." | `analytics.overviewDescription` | "Monitore padrões de uso da API..." |
| 13 | "Run evaluation suites to test..." | `analytics.evalsDescription` | "Execute suítes de avaliação..." |
| 24 | "Analytics" | `analytics.title` | "Análises" |
| 32 | "Overview" | `analytics.overview` | "Visão Geral" |
| 33 | "Evals" | `analytics.evals` | "Avaliações" |
## Checklist
- [ ] Adicionar chaves no `en.json`
- [ ] Adicionar traduções no `pt-BR.json`
- [ ] Substituir strings por `t()` em `page.tsx`
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,31 @@
# Task 03 — API Manager Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `apiManager`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx` | ~400 | ~20 |
## Strings a Traduzir (levantamento parcial — abrir código para completar)
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "API Keys" | `apiManager.title` | "Chaves de API" |
| "Create API Key" | `apiManager.createKey` | "Criar Chave de API" |
| "Name" | `apiManager.name` | "Nome" |
| "Key" | `apiManager.key` | "Chave" |
| "Created" | `apiManager.created` | "Criado em" |
| "Last Used" | `apiManager.lastUsed` | "Último Uso" |
| "Actions" | `apiManager.actions` | "Ações" |
| "Delete" | `common.delete` | ✅ já existe |
| "No API keys found" | `apiManager.noKeys` | "Nenhuma chave de API encontrada" |
| ~11 strings adicionais | — | Levantar no código |
## Checklist
- [ ] Levantar todas as strings do `ApiManagerPageClient.tsx`
- [ ] Adicionar chaves no `en.json`
- [ ] Adicionar traduções no `pt-BR.json`
- [ ] Substituir strings por `t()`
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,31 @@
# Task 04 — Audit Log Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `auditLog`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/audit-log/page.tsx` | 241 | ~12 |
| `src/app/(dashboard)/dashboard/logs/AuditLogTab.tsx` | ~100 | ~3 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Audit Log" | `auditLog.title` | "Log de Auditoria" |
| "Search actions..." | `auditLog.searchPlaceholder` | "Buscar ações..." |
| "Action" | `auditLog.action` | "Ação" |
| "Actor" | `auditLog.actor` | "Autor" |
| "Target" | `auditLog.target` | "Alvo" |
| "Details" | `auditLog.details` | "Detalhes" |
| "IP Address" | `auditLog.ipAddress` | "Endereço IP" |
| "Timestamp" | `auditLog.timestamp` | "Data/Hora" |
| "No audit entries found" | `auditLog.noEntries` | "Nenhum registro de auditoria" |
| "Load More" | `auditLog.loadMore` | "Carregar Mais" |
## Checklist
- [ ] Adicionar chaves no `en.json`
- [ ] Adicionar traduções no `pt-BR.json`
- [ ] Substituir strings em `audit-log/page.tsx` e `AuditLogTab.tsx`
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,37 @@
# Task 05 — CLI Tools Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `cliTools`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `cli-tools/components/AntigravityToolCard.tsx` | ~3 |
| `cli-tools/components/ClaudeToolCard.tsx` | ~3 |
| `cli-tools/components/ClineToolCard.tsx` | ~4 |
| `cli-tools/components/CodexToolCard.tsx` | ~3 |
| `cli-tools/components/DefaultToolCard.tsx` | ~3 |
| `cli-tools/components/DroidToolCard.tsx` | ~2 |
| `cli-tools/components/KiloToolCard.tsx` | ~4 |
| `cli-tools/components/OpenClawToolCard.tsx` | ~2 |
## Strings comuns entre Tool Cards
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Status" | `cliTools.status` | "Status" |
| "Connected" | `cliTools.connected` | "Conectado" |
| "Not Connected" | `cliTools.notConnected` | "Não Conectado" |
| "Configure" | `cliTools.configure` | "Configurar" |
| "Test Connection" | `cliTools.testConnection` | "Testar Conexão" |
| "Models" | `cliTools.models` | "Modelos" |
| "Map Models" | `cliTools.mapModels` | "Mapear Modelos" |
| "Save" | `common.save` | ✅ já existe |
| "Cancel" | `common.cancel` | ✅ já existe |
## Checklist
- [ ] Levantar strings de cada ToolCard
- [ ] Adicionar chaves no `en.json`
- [ ] Adicionar traduções no `pt-BR.json`
- [ ] Substituir por `t()` em cada componente
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,34 @@
# Task 06 — Combos Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `combos`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/combos/page.tsx` | ~1000 | ~20 |
## Strings a Traduzir
| Linha | String EN | Chave i18n | String PT-BR |
|-------|-----------|------------|--------------|
| 207 | "Combos" | `combos.title` | "Combos" |
| 372 | "No models" | `combos.noModels` | "Sem modelos" |
| 747 | "Routing Strategy" | `combos.routingStrategy` | "Estratégia de Roteamento" |
| 791 | "Models" | `combos.models` | "Modelos" |
| 807 | "No models added yet" | `combos.noModelsYet` | "Nenhum modelo adicionado" |
| 922 | "Max Retries" | `combos.maxRetries` | "Máximo de Tentativas" |
| 959 | "Timeout (ms)" | `combos.timeout` | "Timeout (ms)" |
| 977 | "Healthcheck" | `combos.healthcheck` | "Verificação de Saúde" |
| — | "Create Combo" | `combos.create` | "Criar Combo" |
| — | "Edit Combo" | `combos.edit` | "Editar Combo" |
| — | "Delete Combo" | `combos.deleteCombo` | "Excluir Combo" |
| — | "Add Model" | `combos.addModel` | "Adicionar Modelo" |
| — | "Priority" | `combos.priority` | "Prioridade" |
| — | "Fallback" | `combos.fallback` | "Fallback" |
## Checklist
- [ ] Adicionar chaves no `en.json`
- [ ] Adicionar traduções no `pt-BR.json`
- [ ] Substituir strings por `t()` em `combos/page.tsx`
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,23 @@
# Task 07 — Costs Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `costs`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/costs/page.tsx` | ~200 | ~5 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Costs" | `costs.title` | "Custos" |
| "Total Cost" | `costs.totalCost` | "Custo Total" |
| "Cost Breakdown" | `costs.breakdown` | "Detalhamento de Custos" |
| "No cost data" | `costs.noData` | "Sem dados de custo" |
## Checklist
- [ ] Levantar strings completas do código
- [ ] Adicionar chaves no `en.json` / `pt-BR.json`
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,30 @@
# Task 08 — Endpoint Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `endpoint`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx` | ~750 | ~20 |
## Strings a Traduzir
| Linha | String EN | Chave i18n | String PT-BR |
|-------|-----------|------------|--------------|
| 320 | "API Endpoint" | `endpoint.title` | "Endpoint da API" |
| 403 | "Available Endpoints" | `endpoint.available` | "Endpoints Disponíveis" |
| 561 | "Cloud Proxy" | `endpoint.cloudProxy` | "Proxy na Nuvem" |
| 632 | "Note" | `endpoint.note` | "Nota" |
| 717 | "Warning" | `endpoint.warning` | "Aviso" |
| 740 | "Are you sure you want to disable cloud proxy?" | `endpoint.disableConfirm` | "Tem certeza que deseja desativar o proxy na nuvem?" |
| — | "Copy" | `common.copy` | ✅ já existe |
| — | "Base URL" | `endpoint.baseUrl` | "URL Base" |
| — | "Connected" | `endpoint.connected` | "Conectado" |
| — | "Enable" | `endpoint.enable` | "Ativar" |
| — | "Disable" | `endpoint.disable` | "Desativar" |
## Checklist
- [ ] Levantar strings restantes
- [ ] Adicionar chaves no `en.json` / `pt-BR.json`
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,30 @@
# Task 09 — Health Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `health`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/health/page.tsx` | ~350 | ~15 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "System Health" | `health.title` | "Saúde do Sistema" |
| "Healthy" | `health.healthy` | "Saudável" |
| "Degraded" | `health.degraded` | "Degradado" |
| "Down" | `health.down` | "Offline" |
| "Uptime" | `health.uptime` | "Tempo Ativo" |
| "Memory" | `health.memory` | "Memória" |
| "CPU" | `health.cpu` | "CPU" |
| "Database" | `health.database` | "Banco de Dados" |
| "Last Check" | `health.lastCheck` | "Última Verificação" |
| "Refresh" | `common.refresh` | ✅ já existe |
| ~5 strings adicionais | — | Levantar |
## Checklist
- [ ] Levantar strings restantes
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,24 @@
# Task 10 — Limits Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `limits`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/limits/page.tsx` | ~150 | ~5 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Limits & Quotas" | `limits.title` | "Limites e Cotas" |
| "Rate Limit" | `limits.rateLimit` | "Limite de Taxa" |
| "Provider" | `limits.provider` | "Provedor" |
| "Remaining" | `limits.remaining` | "Restante" |
| "Reset" | `limits.reset` | "Reiniciar" |
## Checklist
- [ ] Levantar strings completas
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,24 @@
# Task 11 — Logs Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `logs`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/logs/` | ~200 | ~5 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Logs" | `logs.title` | "Logs" |
| "Request Logs" | `logs.requestLogs` | "Logs de Requisições" |
| "Proxy Logs" | `logs.proxyLogs` | "Logs do Proxy" |
| "Audit Log" | `logs.auditLog` | "Log de Auditoria" |
| "Console" | `logs.console` | "Console" |
## Checklist
- [ ] Levantar strings completas
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,26 @@
# Task 12 — Onboarding Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `onboarding`
## Arquivos
| Arquivo | Linhas | Strings |
|---------|--------|---------|
| `src/app/(dashboard)/dashboard/onboarding/page.tsx` | ~300 | ~10 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Welcome to OmniRoute" | `onboarding.welcome` | "Bem-vindo ao OmniRoute" |
| "Set Password" | `onboarding.setPassword` | "Definir Senha" |
| "Add Provider" | `onboarding.addProvider` | "Adicionar Provedor" |
| "Get Started" | `onboarding.getStarted` | "Começar" |
| "Skip" | `onboarding.skip` | "Pular" |
| "Next" | `common.next` | ✅ já existe |
| ~4 strings adicionais | — | Levantar |
## Checklist
- [ ] Levantar strings completas
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,35 @@
# Task 13 — Providers Pages
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `providers`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `providers/page.tsx` | ~5 |
| `providers/[id]/page.tsx` | ~12 |
| `providers/new/page.tsx` | ~3 |
| `providers/components/ModelAvailabilityPanel.tsx` | ~3 |
| `providers/components/ModelAvailabilityBadge.tsx` | ~1 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Providers" | `providers.title` | "Provedores" |
| "Add Provider" | `providers.add` | "Adicionar Provedor" |
| "Edit Provider" | `providers.edit` | "Editar Provedor" |
| "Test Connection" | `providers.testConnection` | "Testar Conexão" |
| "Connected" | `providers.connected` | "Conectado" |
| "Disconnected" | `providers.disconnected` | "Desconectado" |
| "Models" | `providers.models` | "Modelos" |
| "Accounts" | `providers.accounts` | "Contas" |
| "Delete Provider" | `providers.deleteProvider` | "Excluir Provedor" |
| "No providers configured" | `providers.noProviders` | "Nenhum provedor configurado" |
| "Model Availability" | `providers.modelAvailability` | "Disponibilidade de Modelos" |
| ~9 strings adicionais | — | Levantar |
## Checklist
- [ ] Levantar strings completas de todos os arquivos
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,51 @@
# Task 14 — Settings Page (MAIOR TAREFA)
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `settings`
## Arquivos (20 componentes!)
| Arquivo | Strings |
|---------|---------|
| `settings/components/AppearanceTab.tsx` | ~4 |
| `settings/components/CacheStatsCard.tsx` | ~4 |
| `settings/components/ComboDefaultsTab.tsx` | ~8 |
| `settings/components/FallbackChainsEditor.tsx` | ~2 |
| `settings/components/IPFilterSection.tsx` | ~2 |
| `settings/components/PoliciesPanel.tsx` | ~5 |
| `settings/components/PricingTab.tsx` | ~8 |
| `settings/components/ProxyTab.tsx` | ~2 |
| `settings/components/ResilienceTab.tsx` | ~7 |
| `settings/components/RoutingTab.tsx` | ~4 |
| `settings/components/SecurityTab.tsx` | ~5 |
| `settings/components/SessionInfoCard.tsx` | ~5 |
| `settings/components/SystemPromptTab.tsx` | ~2 |
| `settings/components/SystemStorageTab.tsx` | ~8 |
| `settings/components/ThinkingBudgetTab.tsx` | ~5 |
| `settings/pricing/page.tsx` | ~17 |
## Strings a Traduzir (amostra)
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "General" | `settings.general` | "Geral" |
| "Security" | `settings.security` | "Segurança" |
| "Appearance" | `settings.appearance` | "Aparência" |
| "Routing" | `settings.routing` | "Roteamento" |
| "Cache" | `settings.cache` | "Cache" |
| "Resilience" | `settings.resilience` | "Resiliência" |
| "System Prompt" | `settings.systemPrompt` | "Prompt do Sistema" |
| "Thinking Budget" | `settings.thinkingBudget` | "Orçamento de Raciocínio" |
| "Proxy" | `settings.proxy` | "Proxy" |
| "Pricing" | `settings.pricing` | "Preços" |
| "Storage" | `settings.storage` | "Armazenamento" |
| "Policies" | `settings.policies` | "Políticas" |
| "IP Filter" | `settings.ipFilter` | "Filtro de IP" |
| "Combo Defaults" | `settings.comboDefaults` | "Padrões de Combo" |
| "Fallback Chains" | `settings.fallbackChains` | "Cadeias de Fallback" |
| ~40 strings adicionais | — | Levantar em cada tab |
## Checklist
- [ ] Levantar strings de CADA componente (16 arquivos)
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em todos os 16 arquivos
- [ ] Testar cada aba em EN e PT-BR

View File

@@ -0,0 +1,41 @@
# Task 15 — Translator Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `translator`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `translator/components/LiveMonitorMode.tsx` | ~11 |
| `translator/components/PlaygroundMode.tsx` | ~5 |
| `translator/components/TestBenchMode.tsx` | ~3 |
| `translator/components/ChatTesterMode.tsx` | ~4 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Real-Time Translation Activity" | `translator.realtime` | "Atividade de Tradução em Tempo Real" |
| "Chat Tester" | `translator.chatTester` | "Testador de Chat" |
| "Test Bench" | `translator.testBench` | "Bancada de Testes" |
| "Recent Translations" | `translator.recentTranslations` | "Traduções Recentes" |
| "No translations yet" | `translator.noTranslations` | "Nenhuma tradução ainda" |
| "Time" | `translator.time` | "Tempo" |
| "Source" | `translator.source` | "Origem" |
| "Target" | `translator.target` | "Destino" |
| "Model" | `translator.model` | "Modelo" |
| "Status" | `translator.status` | "Status" |
| "Latency" | `translator.latency` | "Latência" |
| "Format Converter" | `translator.formatConverter` | "Conversor de Formato" |
| "Input" | `translator.input` | "Entrada" |
| "Output" | `translator.output` | "Saída" |
| "Example Templates" | `translator.exampleTemplates` | "Modelos de Exemplo" |
| "Compatibility Tester" | `translator.compatibilityTester` | "Testador de Compatibilidade" |
| "Compatibility Report" | `translator.compatibilityReport` | "Relatório de Compatibilidade" |
| "Pipeline Debugger" | `translator.pipelineDebugger` | "Depurador de Pipeline" |
| "Translation Pipeline" | `translator.translationPipeline` | "Pipeline de Tradução" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em cada componente
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,57 @@
# Task 16 — Usage Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `usage`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `usage/components/BudgetTab.tsx` | ~4 |
| `usage/components/BudgetTelemetryCards.tsx` | ~9 |
| `usage/components/EvalsTab.tsx` | ~6 |
| `usage/components/RateLimitStatus.tsx` | ~2 |
| `usage/components/SessionsTab.tsx` | ~7 |
| `usage/components/ProviderLimits/index.tsx` | ~7 |
| `usage/components/ProviderLimits/ProviderLimitCard.tsx` | ~1 |
| `usage/components/ProviderLimits/QuotaTable.tsx` | ~1 |
## Strings a Traduzir
| String EN | Chave i18n | String PT-BR |
|-----------|------------|--------------|
| "Budget Management" | `usage.budgetManagement` | "Gerenciamento de Orçamento" |
| "API Key" | `usage.apiKey` | "Chave de API" |
| "This Month" | `usage.thisMonth` | "Este Mês" |
| "Set Limits" | `usage.setLimits` | "Definir Limites" |
| "Total requests" | `usage.totalRequests` | "Total de requisições" |
| "No data yet" | `usage.noData` | "Sem dados ainda" |
| "Entries" | `usage.entries` | "Entradas" |
| "Hit Rate" | `usage.hitRate` | "Taxa de Acerto" |
| "Circuit Breakers" | `usage.circuitBreakers` | "Disjuntores" |
| "Locked IPs" | `usage.lockedIPs` | "IPs Bloqueados" |
| "How It Works" | `usage.howItWorks` | "Como Funciona" |
| "Define" | `usage.define` | "Definir" |
| "Run" | `usage.run` | "Executar" |
| "Evaluate" | `usage.evaluate` | "Avaliar" |
| "Evaluation Suites" | `usage.evalSuites` | "Suítes de Avaliação" |
| "Model Evaluations" | `usage.modelEvals` | "Avaliações de Modelos" |
| "Model Lockouts" | `usage.modelLockouts` | "Bloqueios de Modelo" |
| "No models currently locked" | `usage.noLockouts` | "Nenhum modelo bloqueado" |
| "Active Sessions" | `usage.activeSessions` | "Sessões Ativas" |
| "No active sessions" | `usage.noSessions` | "Sem sessões ativas" |
| "Session" | `usage.session` | "Sessão" |
| "Age" | `usage.age` | "Idade" |
| "Requests" | `usage.requests` | "Requisições" |
| "Connection" | `usage.connection` | "Conexão" |
| "Provider Limits" | `usage.providerLimits` | "Limites do Provedor" |
| "No Providers Connected" | `usage.noProviders` | "Nenhum Provedor Conectado" |
| "Account" | `usage.account` | "Conta" |
| "Model Quotas" | `usage.modelQuotas` | "Cotas de Modelo" |
| "Last Used" | `usage.lastUsed` | "Último Uso" |
| "Actions" | `usage.actions` | "Ações" |
| "No quota data" | `usage.noQuota` | "Sem dados de cota" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em cada componente
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,44 @@
# Task 17 — Shared Modals
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `modals`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `shared/components/OAuthModal.tsx` | ~6 |
| `shared/components/KiroAuthModal.tsx` | ~10 |
| `shared/components/KiroSocialOAuthModal.tsx` | ~3 |
| `shared/components/CursorAuthModal.tsx` | ~2 |
| `shared/components/PricingModal.tsx` | ~10 |
| `shared/components/ModelSelectModal.tsx` | ~2 |
| `shared/components/ProxyConfigModal.tsx` | ~1 |
## Strings a Traduzir
| String EN | String PT-BR |
|-----------|--------------|
| "Waiting for Authorization" | "Aguardando Autorização" |
| "Verification URL" | "URL de Verificação" |
| "Your Code" | "Seu Código" |
| "Remote access:" | "Acesso remoto:" |
| "Connected Successfully!" | "Conectado com Sucesso!" |
| "Connection Failed" | "Falha na Conexão" |
| "Choose your authentication method:" | "Escolha seu método de autenticação:" |
| "AWS Builder ID" | "AWS Builder ID" |
| "AWS IAM Identity Center" | "AWS IAM Identity Center" |
| "Google Account" | "Conta Google" |
| "GitHub Account" | "Conta GitHub" |
| "Import Token" | "Importar Token" |
| "Auto-detecting tokens..." | "Detectando tokens automaticamente..." |
| "Pricing Configuration" | "Configuração de Preços" |
| "Loading pricing data..." | "Carregando dados de preços..." |
| "Model" / "Input" / "Output" / "Cached" | "Modelo" / "Entrada" / "Saída" / "Em Cache" |
| "Combos" | "Combos" |
| "No models found" | "Nenhum modelo encontrado" |
| "Connected" | "Conectado" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em cada modal
- [ ] Testar cada modal em EN e PT-BR

View File

@@ -0,0 +1,37 @@
# Task 18 — Shared Loggers
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `loggers`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `shared/components/RequestLoggerV2.tsx` | ~12 |
| `shared/components/RequestLoggerDetail.tsx` | ~4 |
| `shared/components/ProxyLogger.tsx` | ~7 |
| `shared/components/ProxyLogDetail.tsx` | ~6 |
| `shared/components/ConsoleLogViewer.tsx` | ~2 |
## Strings a Traduzir
| String EN | String PT-BR |
|-----------|--------------|
| "All Providers" | "Todos os Provedores" |
| "All Models" | "Todos os Modelos" |
| "All Accounts" | "Todas as Contas" |
| "All API Keys" | "Todas as Chaves de API" |
| "Newest" / "Oldest" | "Mais Recente" / "Mais Antigo" |
| "Model A-Z" / "Model Z-A" | "Modelo A-Z" / "Modelo Z-A" |
| "Columns" | "Colunas" |
| "Loading logs..." | "Carregando logs..." |
| "All Types" | "Todos os Tipos" |
| "All Levels" | "Todos os Níveis" |
| "Proxy Event" | "Evento do Proxy" |
| "Time" / "Model" / "Combo" | "Tempo" / "Modelo" / "Combo" |
| "No log entries found" | "Nenhuma entrada de log encontrada" |
| "No payload data available" | "Nenhum dado de payload disponível" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em cada logger
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,36 @@
# Task 19 — Shared Charts & Stats
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `stats`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `shared/components/UsageStats.tsx` | ~6 |
| `shared/components/analytics/charts.tsx` | ~15 |
| `shared/components/TokenHealthBadge.tsx` | ~6 |
| `shared/components/SystemMonitor.tsx` | ~1 |
| `shared/components/Footer.tsx` | ~3 |
## Strings a Traduzir
| String EN | String PT-BR |
|-----------|--------------|
| "Usage Overview" | "Visão Geral de Uso" |
| "Output Tokens" | "Tokens de Saída" |
| "Total Cost" | "Custo Total" |
| "Usage by Model" | "Uso por Modelo" |
| "Usage by Account" | "Uso por Conta" |
| "Failed to load usage statistics." | "Falha ao carregar estatísticas." |
| "Token Health" | "Saúde dos Tokens" |
| "Total OAuth" | "Total OAuth" |
| "Healthy" / "Errored" / "Warning" | "Saudável" / "Com Erro" / "Aviso" |
| "Last check" | "Última verificação" |
| "No data" / "Share" | "Sem dados" / "Compartilhar" |
| "Unable to load system metrics" | "Não foi possível carregar métricas" |
| "Product" / "Resources" / "Company" (Footer) | "Produto" / "Recursos" / "Empresa" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em cada componente
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,36 @@
# Task 20 — Login & Auth Pages
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `auth`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `src/app/login/page.tsx` | ~8 |
| `src/app/forgot-password/page.tsx` | ~3 |
| `src/app/callback/page.tsx` | ~5 |
| `src/app/forbidden/page.tsx` | ~1 |
## Strings a Traduzir
| String EN | String PT-BR |
|-----------|--------------|
| "Welcome" | "Bem-vindo" |
| "OmniRoute" | "OmniRoute" (não traduzir) |
| "Sign in" | "Entrar" |
| "Enter your password to continue" | "Digite sua senha para continuar" |
| "Password" | "Senha" |
| "Unified AI API Proxy" | "Proxy Unificado de API de IA" |
| "Loading..." | "Carregando..." |
| "Password protection is not enabled" | "Proteção por senha não está ativada" |
| "Reset Password" | "Redefinir Senha" |
| "Choose a method to recover access" | "Escolha um método para recuperar acesso" |
| "Processing..." | "Processando..." |
| "Authorization Successful!" | "Autorização bem-sucedida!" |
| "Copy This URL" | "Copiar esta URL" |
| "Access Denied" | "Acesso Negado" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` em cada página
- [ ] Testar em EN e PT-BR

View File

@@ -0,0 +1,34 @@
# Task 21 — Landing Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `landing`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `landing/components/HeroSection.tsx` | ~3 |
| `landing/components/Features.tsx` | ~10 |
| `landing/components/HowItWorks.tsx` | ~5 |
| `landing/components/GetStarted.tsx` | ~5 |
| `landing/components/Navigation.tsx` | ~2 |
| `landing/components/FlowAnimation.tsx` | ~2 |
| `landing/components/Footer.tsx` | ~5 |
## Strings a Traduzir (amostra)
| String EN | String PT-BR |
|-----------|--------------|
| "All AI Providers" | "Todos os Provedores de IA" |
| "One Endpoint" | "Um Endpoint" |
| "Powerful Features" | "Recursos Poderosos" |
| "How OmniRoute Works" | "Como o OmniRoute Funciona" |
| "Install OmniRoute" | "Instalar o OmniRoute" |
| "Open Dashboard" | "Abrir Painel" |
| "Route Requests" | "Rotear Requisições" |
| "Data Location:" | "Local dos Dados:" |
| "Product" / "Resources" / "Legal" | "Produto" / "Recursos" / "Legal" |
## Checklist
- [ ] Levantar strings completas de cada componente
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,32 @@
# Task 22 — Docs Page
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `docs`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `src/app/docs/page.tsx` | ~25 |
## Strings a Traduzir
| String EN | String PT-BR |
|-----------|--------------|
| "Quick Start" | "Início Rápido" |
| "Features" | "Recursos" |
| "Supported Providers" | "Provedores Suportados" |
| "Common Use Cases" | "Casos de Uso Comuns" |
| "Client Compatibility" | "Compatibilidade de Clientes" |
| "Cherry Studio" | "Cherry Studio" (não traduzir) |
| "Codex / GitHub Copilot Models" | "Modelos Codex / GitHub Copilot" |
| "Cursor IDE" | "Cursor IDE" (não traduzir) |
| "Claude Code / Antigravity" | "Claude Code / Antigravity" |
| "API Reference" | "Referência da API" |
| "Method" / "Path" / "Notes" | "Método" / "Caminho" / "Notas" |
| "Model Prefixes" | "Prefixos de Modelo" |
| "Prefix" / "Provider" / "Type" | "Prefixo" / "Provedor" / "Tipo" |
| "Troubleshooting" | "Solução de Problemas" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar

View File

@@ -0,0 +1,31 @@
# Task 23 — Legal Pages (Privacy & Terms)
**Status:** `[ ]` Não iniciado
**Namespace JSON:** `legal`
## Arquivos
| Arquivo | Strings |
|---------|---------|
| `src/app/privacy/page.tsx` | ~10 |
| `src/app/terms/page.tsx` | ~5 |
## Strings a Traduzir
| String EN | String PT-BR |
|-----------|--------------|
| "Privacy Policy" | "Política de Privacidade" |
| "Terms of Service" | "Termos de Serviço" |
| "Provider configurations" | "Configurações de provedores" |
| "API keys" | "Chaves de API" |
| "Usage logs" | "Logs de uso" |
| "Application settings" | "Configurações do aplicativo" |
| "View and export usage analytics" | "Visualizar e exportar análises de uso" |
| "Clear usage history at any time" | "Limpar histórico de uso a qualquer momento" |
| "Configure log retention policies" | "Configurar políticas de retenção de logs" |
| "Back up and restore your database" | "Fazer backup e restaurar seu banco de dados" |
## Checklist
- [ ] Adicionar chaves / traduções
- [ ] Substituir por `t()` e testar
> **Nota:** Textos legais podem requerer revisão jurídica para tradução formal.

51
docs/i18n-tasks/README.md Normal file
View File

@@ -0,0 +1,51 @@
# i18n Translation Tasks
Cada arquivo `.md` nesta pasta representa **uma tarefa de tradução** para uma página ou componente do OmniRoute.
## Status Legend
- `[ ]` — Não iniciado
- `[/]` — Em progresso
- `[x]` — Concluído
## Dashboard Pages (~260 strings)
| # | Tarefa | Arquivo | Strings | Status |
| --- | ---------------------------------- | ----------------------------------------------------- | ------- | ------ |
| 01 | [Home](./01-home.md) | `HomePageClient.tsx` | ~25 | `[ ]` |
| 02 | [Analytics](./02-analytics.md) | `analytics/page.tsx` | ~8 | `[ ]` |
| 03 | [API Manager](./03-api-manager.md) | `api-manager/` | ~20 | `[ ]` |
| 04 | [Audit Log](./04-audit-log.md) | `audit-log/page.tsx`, `logs/AuditLogTab.tsx` | ~15 | `[ ]` |
| 05 | [CLI Tools](./05-cli-tools.md) | `cli-tools/components/*.tsx` | ~20 | `[ ]` |
| 06 | [Combos](./06-combos.md) | `combos/page.tsx` | ~20 | `[ ]` |
| 07 | [Costs](./07-costs.md) | `costs/page.tsx` | ~5 | `[ ]` |
| 08 | [Endpoint](./08-endpoint.md) | `endpoint/EndpointPageClient.tsx` | ~20 | `[ ]` |
| 09 | [Health](./09-health.md) | `health/page.tsx` | ~15 | `[ ]` |
| 10 | [Limits](./10-limits.md) | `limits/page.tsx` | ~5 | `[ ]` |
| 11 | [Logs](./11-logs.md) | `logs/` | ~5 | `[ ]` |
| 12 | [Onboarding](./12-onboarding.md) | `onboarding/page.tsx` | ~10 | `[ ]` |
| 13 | [Providers](./13-providers.md) | `providers/page.tsx`, `[id]/page.tsx`, `new/page.tsx` | ~20 | `[ ]` |
| 14 | [Settings](./14-settings.md) | `settings/components/*.tsx` | ~55 | `[ ]` |
| 15 | [Translator](./15-translator.md) | `translator/components/*.tsx` | ~25 | `[ ]` |
| 16 | [Usage](./16-usage.md) | `usage/components/*.tsx` | ~35 | `[ ]` |
## Shared Components (~95 strings)
| # | Tarefa | Arquivo(s) | Strings | Status |
| --- | ---------------------------------------------- | ---------------------------------------------------- | ------- | ------ |
| 17 | [Shared Modals](./17-shared-modals.md) | `OAuthModal`, `KiroAuthModal`, `PricingModal`, etc. | ~40 | `[ ]` |
| 18 | [Shared Loggers](./18-shared-loggers.md) | `RequestLoggerV2`, `ProxyLogger`, `ProxyLogDetail` | ~30 | `[ ]` |
| 19 | [Shared Charts & Stats](./19-shared-charts.md) | `UsageStats`, `analytics/charts`, `TokenHealthBadge` | ~25 | `[ ]` |
## Non-Dashboard Pages (~75 strings)
| # | Tarefa | Arquivo(s) | Strings | Status |
| --- | ---------------------------------- | ------------------------------------------------------- | ------- | ------ |
| 20 | [Login & Auth](./20-login-auth.md) | `login/`, `forgot-password/`, `callback/`, `forbidden/` | ~20 | `[ ]` |
| 21 | [Landing Page](./21-landing.md) | `landing/components/*.tsx` | ~25 | `[ ]` |
| 22 | [Docs Page](./22-docs.md) | `docs/page.tsx` | ~25 | `[ ]` |
| 23 | [Legal Pages](./23-legal.md) | `privacy/`, `terms/` | ~15 | `[ ]` |
---
**Total estimado: ~460 strings em 23 tarefas**

View File

@@ -1,3 +1,7 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
turbopack: {},
@@ -6,8 +10,8 @@ const nextConfig = {
transpilePackages: ["@omniroute/open-sse"],
allowedDevOrigins: ["192.168.*"],
typescript: {
// All TS errors resolved — strict checking enforced
ignoreBuildErrors: false,
// TODO: Re-enable after fixing all sub-component useTranslations scope issues
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
@@ -63,4 +67,4 @@ const nextConfig = {
},
};
export default nextConfig;
export default withNextIntl(nextConfig);

View File

@@ -43,6 +43,7 @@ export interface RegistryEntry {
extraHeaders?: Record<string, string>;
oauth?: RegistryOAuth;
models: RegistryModel[];
modelsUrl?: string;
chatPath?: string;
clientVersion?: string;
passthroughModels?: boolean;
@@ -107,7 +108,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientIdEnv: "GEMINI_OAUTH_CLIENT_ID",
clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
clientSecretDefault: "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",
},
models: [
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
@@ -133,7 +134,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientIdEnv: "GEMINI_CLI_OAUTH_CLIENT_ID",
clientIdDefault: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecretEnv: "GEMINI_CLI_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
clientSecretDefault: "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",
},
models: [
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
@@ -211,7 +212,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
id: "iflow",
alias: "if",
format: "openai",
executor: "default",
executor: "iflow",
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
authType: "oauth",
authHeader: "bearer",
@@ -222,7 +223,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientIdEnv: "IFLOW_OAUTH_CLIENT_ID",
clientIdDefault: "10009311001",
clientSecretEnv: "IFLOW_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
clientSecretDefault: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
tokenUrl: "https://iflow.cn/oauth/token",
authUrl: "https://iflow.cn/oauth",
},
@@ -260,18 +261,15 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
clientIdDefault: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
clientSecretDefault: "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",
},
models: [
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking" },
{ id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High" },
{ id: "gemini-3-pro-low", name: "Gemini 3 Pro Low" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
],
},
@@ -503,6 +501,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
format: "openrouter",
executor: "openrouter",
baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions",
modelsUrl: "https://api.kilo.ai/api/openrouter/models",
authType: "oauth",
authHeader: "Authorization",
authPrefix: "Bearer ",
@@ -511,14 +510,32 @@ export const REGISTRY: Record<string, RegistryEntry> = {
pollUrlBase: "https://api.kilo.ai/api/device-auth/codes",
},
models: [
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
{ id: "openai/o3", name: "o3" },
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
{ id: "openrouter/free", name: "Free Models Router" },
{ id: "qwen/qwen3-vl-235b-a22b-thinking", name: "Qwen3 VL 235B A22B Thinking" },
{ id: "qwen/qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking 2507" },
{ id: "qwen/qwen3-vl-30b-a3b-thinking", name: "Qwen3 VL 30B A3B Thinking" },
{ id: "stepfun/step-3.5-flash:free", name: "StepFun Step 3.5 Flash" },
{ id: "arcee-ai/trinity-large-preview:free", name: "Arcee AI Trinity Large Preview" },
{ id: "openai/gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "openai/gpt-4.1-nano", name: "GPT-4.1 Nano" },
{ id: "openai/gpt-5-nano", name: "GPT-5 Nano" },
{ id: "openai/gpt-5-mini", name: "GPT-5 Mini" },
{ id: "anthropic/claude-3-haiku", name: "Claude 3 Haiku" },
{ id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "google/gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
{ id: "deepseek/deepseek-chat-v3.1", name: "DeepSeek V3.1" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek V3.2" },
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "meta-llama/llama-4-scout", name: "Llama 4 Scout" },
{ id: "meta-llama/llama-4-maverick", name: "Llama 4 Maverick" },
{ id: "qwen/qwen3-8b", name: "Qwen3 8B" },
{ id: "qwen/qwen3-32b", name: "Qwen3 32B" },
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B" },
{ id: "qwen/qwq-32b", name: "QwQ 32B" },
{ id: "mistralai/mistral-small-24b-instruct-2501", name: "Mistral Small 3" },
{ id: "mistralai/mistral-7b-instruct", name: "Mistral 7B" },
{ id: "x-ai/grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
],
passthroughModels: true,
},

View File

@@ -4,6 +4,15 @@ import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"
const MAX_RETRY_AFTER_MS = 10000;
/**
* Strip any provider prefix (e.g. "antigravity/model" → "model").
* Ensures the model name sent to the upstream API never contains a routing prefix.
*/
function cleanModelName(model: string): string {
if (!model) return model;
return model.includes("/") ? model.split("/").pop()! : model;
}
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
@@ -60,10 +69,12 @@ export class AntigravityExecutor extends BaseExecutor {
: body.request?.toolConfig,
};
const upstreamModel = cleanModelName(model);
return {
...body,
project: projectId,
model: model,
model: upstreamModel,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,

View File

@@ -4,7 +4,8 @@ import { PROVIDERS } from "../config/constants.ts";
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing
* Automatically injects default instructions if missing.
* IMPORTANT: Includes chatgpt-account-id header for workspace binding.
*/
export class CodexExecutor extends BaseExecutor {
constructor() {
@@ -14,9 +15,18 @@ export class CodexExecutor extends BaseExecutor {
/**
* Codex Responses endpoint is SSE-first.
* Always request event-stream from upstream, even when client requested stream=false.
* Includes chatgpt-account-id header for strict workspace binding.
*/
buildHeaders(credentials, stream = true) {
return super.buildHeaders(credentials, true);
const headers = super.buildHeaders(credentials, true);
// Add workspace binding header if workspaceId is persisted
const workspaceId = credentials?.providerSpecificData?.workspaceId;
if (workspaceId) {
headers["chatgpt-account-id"] = workspaceId;
}
return headers;
}
/**

View File

@@ -0,0 +1,97 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
/**
* IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature.
*
* iFlow requires custom headers including a session ID, timestamp,
* and an HMAC-SHA256 signature for request authentication.
* Without these headers, the API returns a 406 error.
*
* Fixes: https://github.com/diegosouzapw/OmniRoute/issues/114
*/
export class IFlowExecutor extends BaseExecutor {
constructor() {
super("iflow", PROVIDERS.iflow);
}
/**
* Create iFlow signature using HMAC-SHA256
* @param userAgent - User agent string
* @param sessionID - Session ID
* @param timestamp - Unix timestamp in milliseconds
* @param apiKey - API key for signing
* @returns Hex-encoded signature
*/
createIFlowSignature(
userAgent: string,
sessionID: string,
timestamp: number,
apiKey: string
): string {
if (!apiKey) return "";
const payload = `${userAgent}:${sessionID}:${timestamp}`;
const hmac = crypto.createHmac("sha256", apiKey);
hmac.update(payload);
return hmac.digest("hex");
}
/**
* Build headers with iFlow-specific HMAC-SHA256 signature.
* Includes session-id, x-iflow-timestamp, and x-iflow-signature.
*/
buildHeaders(credentials: any, stream = true) {
// Generate session ID and timestamp
const sessionID = `session-${crypto.randomUUID()}`;
const timestamp = Date.now();
// Get user agent from config
const userAgent = this.config.headers?.["User-Agent"] || "iFlow-Cli";
// Get API key (prefer apiKey, fallback to accessToken)
const apiKey = credentials.apiKey || credentials.accessToken || "";
// Create HMAC-SHA256 signature
const signature = this.createIFlowSignature(userAgent, sessionID, timestamp, apiKey);
// Build headers
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.config.headers,
"session-id": sessionID,
"x-iflow-timestamp": timestamp.toString(),
"x-iflow-signature": signature,
};
// Add authorization
if (credentials.apiKey) {
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
// Add streaming header
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
/**
* Build URL for iFlow API — uses baseUrl directly.
*/
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
return this.config.baseUrl;
}
/**
* Transform request body (passthrough for iFlow).
*/
transformRequest(model: string, body: any, stream: boolean, credentials: any) {
return body;
}
}
export default IFlowExecutor;

View File

@@ -1,6 +1,7 @@
import { AntigravityExecutor } from "./antigravity.ts";
import { GeminiCLIExecutor } from "./gemini-cli.ts";
import { GithubExecutor } from "./github.ts";
import { IFlowExecutor } from "./iflow.ts";
import { KiroExecutor } from "./kiro.ts";
import { CodexExecutor } from "./codex.ts";
import { CursorExecutor } from "./cursor.ts";
@@ -10,6 +11,7 @@ const executors = {
antigravity: new AntigravityExecutor(),
"gemini-cli": new GeminiCLIExecutor(),
github: new GithubExecutor(),
iflow: new IFlowExecutor(),
kiro: new KiroExecutor(),
codex: new CodexExecutor(),
cursor: new CursorExecutor(),
@@ -32,6 +34,7 @@ export { BaseExecutor } from "./base.ts";
export { AntigravityExecutor } from "./antigravity.ts";
export { GeminiCLIExecutor } from "./gemini-cli.ts";
export { GithubExecutor } from "./github.ts";
export { IFlowExecutor } from "./iflow.ts";
export { KiroExecutor } from "./kiro.ts";
export { CodexExecutor } from "./codex.ts";
export { CursorExecutor } from "./cursor.ts";

View File

@@ -22,9 +22,7 @@ const PROVIDER_MODEL_ALIASES = {
"gemini-3-flash": "gemini-3-flash-preview",
"raptor-mini": "oswe-vscode-prime",
},
antigravity: {
"gemini-3-flash": "gemini-3-flash-preview",
},
antigravity: {},
};
// Reverse index: modelId -> providerIds that expose this model

View File

@@ -53,7 +53,7 @@ export async function getUsageForProvider(connection) {
case "claude":
return await getClaudeUsage(accessToken);
case "codex":
return await getCodexUsage(accessToken);
return await getCodexUsage(accessToken, providerSpecificData);
case "kiro":
return await getKiroUsage(accessToken, providerSpecificData);
case "qwen":
@@ -318,14 +318,11 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
const importantModels = [
"claude-opus-4-6-thinking",
"claude-opus-4-5-thinking",
"claude-opus-4-5",
"claude-sonnet-4-5-thinking",
"claude-sonnet-4-5",
"gemini-3-pro-high",
"gemini-3-pro-low",
"claude-sonnet-4-6",
"gemini-3.1-pro-high",
"gemini-3.1-pro-low",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-oss-120b-medium",
];
for (const [modelKey, info] of Object.entries(data.models) as [string, any][]) {
@@ -483,38 +480,17 @@ async function getClaudeUsage(accessToken) {
/**
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
* IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding.
* No fallback to other workspaces - strict binding to user's selected workspace.
*/
async function getCodexUsage(accessToken) {
async function getCodexUsage(accessToken, providerSpecificData: Record<string, any> = {}) {
try {
let accountId = null;
try {
const accountsRes = await fetch("https://chatgpt.com/backend-api/accounts/check/v4", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
if (accountsRes.ok) {
const accountsData = await accountsRes.json();
if (accountsData.accounts) {
const accountsArray = Object.values(accountsData.accounts) as any[];
const targetWorkspace =
accountsArray.find((a) => a.account?.plan_type === "biz") ||
accountsArray.find((a) => a.account?.plan_type !== "free") ||
accountsArray.find((a) => a.is_default) ||
accountsArray[0];
if (targetWorkspace && targetWorkspace.account?.id) {
accountId = targetWorkspace.account.id;
}
}
}
} catch (err) {
console.warn("Could not fetch ChatGPT accounts for quota:", err);
}
// Use persisted workspace ID from OAuth - NO FALLBACK
const accountId = providerSpecificData?.workspaceId || null;
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
};
if (accountId) {
@@ -532,38 +508,75 @@ async function getCodexUsage(accessToken) {
const data = await response.json();
// Parse rate limit info
const rateLimit = data.rate_limit || {};
const primaryWindow = rateLimit.primary_window || {};
const secondaryWindow = rateLimit.secondary_window || {};
// Helper to get field with snake_case/camelCase fallback
const getField = (obj: any, snakeKey: string, camelKey: string) =>
obj?.[snakeKey] ?? obj?.[camelKey] ?? null;
// Parse reset dates (reset_at is Unix timestamp in seconds, multiply by 1000 for ms)
const sessionResetAt = parseResetTime(
primaryWindow.reset_at ? primaryWindow.reset_at * 1000 : null
);
const weeklyResetAt = parseResetTime(
secondaryWindow.reset_at ? secondaryWindow.reset_at * 1000 : null
// Parse rate limit info (supports both snake_case and camelCase)
const rateLimit = getField(data, "rate_limit", "rateLimit") || {};
const primaryWindow = getField(rateLimit, "primary_window", "primaryWindow") || {};
const secondaryWindow = getField(rateLimit, "secondary_window", "secondaryWindow") || {};
// Parse reset times (reset_at is Unix timestamp in seconds)
const parseWindowReset = (window: any) => {
const resetAt = getField(window, "reset_at", "resetAt");
const resetAfterSeconds = getField(window, "reset_after_seconds", "resetAfterSeconds");
if (resetAt) return parseResetTime(resetAt * 1000);
if (resetAfterSeconds) return parseResetTime(Date.now() + resetAfterSeconds * 1000);
return null;
};
// Build quota windows
const quotas: Record<string, any> = {};
// Primary window (5-hour)
if (Object.keys(primaryWindow).length > 0) {
quotas.session = {
used: getField(primaryWindow, "used_percent", "usedPercent") || 0,
total: 100,
remaining: 100 - (getField(primaryWindow, "used_percent", "usedPercent") || 0),
resetAt: parseWindowReset(primaryWindow),
unlimited: false,
};
}
// Secondary window (weekly)
if (Object.keys(secondaryWindow).length > 0) {
quotas.weekly = {
used: getField(secondaryWindow, "used_percent", "usedPercent") || 0,
total: 100,
remaining: 100 - (getField(secondaryWindow, "used_percent", "usedPercent") || 0),
resetAt: parseWindowReset(secondaryWindow),
unlimited: false,
};
}
// Code review rate limit (3rd window — differs per plan: Plus/Pro/Team)
const codeReviewRateLimit =
getField(data, "code_review_rate_limit", "codeReviewRateLimit") || {};
const codeReviewWindow = getField(codeReviewRateLimit, "primary_window", "primaryWindow") || {};
// Only include code review quota if the API returned data for it
const codeReviewUsedPercent = getField(codeReviewWindow, "used_percent", "usedPercent");
const codeReviewRemainingCount = getField(
codeReviewWindow,
"remaining_count",
"remainingCount"
);
if (codeReviewUsedPercent !== null || codeReviewRemainingCount !== null) {
quotas.code_review = {
used: codeReviewUsedPercent || 0,
total: 100,
remaining: 100 - (codeReviewUsedPercent || 0),
resetAt: parseWindowReset(codeReviewWindow),
unlimited: false,
};
}
return {
plan: data.plan_type || "unknown",
limitReached: rateLimit.limit_reached || false,
quotas: {
session: {
used: primaryWindow.used_percent || 0,
total: 100,
remaining: 100 - (primaryWindow.used_percent || 0),
resetAt: sessionResetAt,
unlimited: false,
},
weekly: {
used: secondaryWindow.used_percent || 0,
total: 100,
remaining: 100 - (secondaryWindow.used_percent || 0),
resetAt: weeklyResetAt,
unlimited: false,
},
},
plan: getField(data, "plan_type", "planType") || "unknown",
limitReached: getField(rateLimit, "limit_reached", "limitReached") || false,
quotas,
};
} catch (error) {
throw new Error(`Failed to fetch Codex usage: ${error.message}`);

View File

@@ -271,9 +271,11 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) {
const projectId = credentials?.projectId || generateProjectId();
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
const envelope: Record<string, any> = {
project: projectId,
model: model,
model: cleanModel,
userAgent: isAntigravity ? "antigravity" : "gemini-cli",
requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(),
request: {
@@ -315,9 +317,11 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) {
const projectId = credentials?.projectId || generateProjectId();
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
const envelope: Record<string, any> = {
project: projectId,
model: model,
model: cleanModel,
userAgent: "antigravity",
requestId: `agent-${generateUUID()}`,
requestType: "agent",

1179
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "1.1.0",
"version": "1.4.11",
"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": {
@@ -17,7 +17,7 @@
"open-sse"
],
"engines": {
"node": ">=18.0.0"
"node": ">=18.0.0 <24.0.0"
},
"keywords": [
"ai",
@@ -47,7 +47,7 @@
"build:cli": "node scripts/prepublish.mjs",
"start": "node scripts/run-next.mjs start",
"lint": "eslint .",
"test": "node --test tests/unit/*.test.mjs",
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
"test:plan3": "node --test tests/unit/plan3-p0.test.mjs",
"test:fixes": "node --test tests/unit/fixes-p1.test.mjs",
@@ -72,6 +72,7 @@
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
"ora": "^9.1.0",
@@ -90,7 +91,7 @@
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4.1.18",
"@types/bcryptjs": "^2.4.6",
"@types/bcryptjs": "^3.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import Image from "next/image";
@@ -10,6 +12,9 @@ import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constant
import { useNotificationStore } from "@/store/notificationStore";
export default function HomePageClient({ machineId }) {
const t = useTranslations("home");
const tc = useTranslations("common");
const ts = useTranslations("sidebar");
const [providerConnections, setProviderConnections] = useState([]);
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
@@ -103,14 +108,14 @@ export default function HomePageClient({ machineId }) {
}, [selectedProvider, models]);
const quickStartLinks = [
{ label: "Documentation", href: "/docs", icon: "menu_book" },
{ label: "Providers", href: "/dashboard/providers", icon: "dns" },
{ label: "Combos", href: "/dashboard/combos", icon: "layers" },
{ label: "Analytics", href: "/dashboard/analytics", icon: "analytics" },
{ label: "Health Monitor", href: "/dashboard/health", icon: "health_and_safety" },
{ label: "CLI Tools", href: "/dashboard/cli-tools", icon: "terminal" },
{ label: t("documentation"), href: "/docs", icon: "menu_book" },
{ label: ts("providers"), href: "/dashboard/providers", icon: "dns" },
{ label: ts("combos"), href: "/dashboard/combos", icon: "layers" },
{ label: ts("analytics"), href: "/dashboard/analytics", icon: "analytics" },
{ label: t("healthMonitor"), href: "/dashboard/health", icon: "health_and_safety" },
{ label: ts("cliTools"), href: "/dashboard/cli-tools", icon: "terminal" },
{
label: "Report issue",
label: t("reportIssue"),
href: "https://github.com/diegosouzapw/OmniRoute/issues",
external: true,
icon: "bug_report",
@@ -135,17 +140,15 @@ export default function HomePageClient({ machineId }) {
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Quick Start</h2>
<p className="text-sm text-text-muted">
Get up and running in 4 steps. Connect providers, route models, monitor everything.
</p>
<h2 className="text-lg font-semibold">{t("quickStart")}</h2>
<p className="text-sm text-text-muted">{t("quickStartDesc")}</p>
</div>
<Link
href="/docs"
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">menu_book</span>
Full Docs
{t("fullDocs")}
</Link>
</div>
@@ -155,13 +158,15 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">key</span>
</div>
<div>
<span className="font-semibold">1. Create API key</span>
<span className="font-semibold">{t("step1Title")}</span>
<p className="text-text-muted mt-0.5">
Go to{" "}
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
Endpoint
</Link>{" "}
Registered Keys. Generate one key per environment.
{t.rich("step1Desc", {
endpoint: (chunks) => (
<Link href="/dashboard/endpoint" className="text-primary hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
</li>
@@ -170,13 +175,15 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<div>
<span className="font-semibold">2. Connect providers</span>
<span className="font-semibold">{t("step2Title")}</span>
<p className="text-text-muted mt-0.5">
Add accounts in{" "}
<Link href="/dashboard/providers" className="text-primary hover:underline">
Providers
</Link>
. Supports OAuth, API Key, and free tiers.
{t.rich("step2Desc", {
providers: (chunks) => (
<Link href="/dashboard/providers" className="text-primary hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
</li>
@@ -185,14 +192,8 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">link</span>
</div>
<div>
<span className="font-semibold">3. Point your client</span>
<p className="text-text-muted mt-0.5">
Set base URL to{" "}
<code className="px-1.5 py-0.5 rounded bg-surface text-xs font-mono">
{currentEndpoint}
</code>{" "}
in your IDE or API client.
</p>
<span className="font-semibold">{t("step3Title")}</span>
<p className="text-text-muted mt-0.5">{t("step3Desc", { url: currentEndpoint })}</p>
</div>
</li>
<li className="rounded-lg border border-border bg-bg-subtle p-4 flex gap-3">
@@ -200,17 +201,20 @@ export default function HomePageClient({ machineId }) {
<span className="material-symbols-outlined text-[18px]">analytics</span>
</div>
<div>
<span className="font-semibold">4. Monitor & optimize</span>
<span className="font-semibold">{t("step4Title")}</span>
<p className="text-text-muted mt-0.5">
Track tokens, cost and errors in{" "}
<Link href="/dashboard/usage" className="text-primary hover:underline">
Request Logs
</Link>{" "}
and{" "}
<Link href="/dashboard/analytics" className="text-primary hover:underline">
Analytics
</Link>
.
{t.rich("step4Desc", {
logs: (chunks) => (
<Link href="/dashboard/usage" className="text-primary hover:underline">
{chunks}
</Link>
),
analytics: (chunks) => (
<Link href="/dashboard/analytics" className="text-primary hover:underline">
{chunks}
</Link>
),
})}
</p>
</div>
</li>
@@ -239,22 +243,24 @@ export default function HomePageClient({ machineId }) {
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Providers Overview</h2>
<h2 className="text-lg font-semibold">{t("providersOverview")}</h2>
<p className="text-sm text-text-muted">
{providerStats.filter((item) => item.total > 0).length} configured of{" "}
{providerStats.length} available providers
{t("configuredOf", {
configured: providerStats.filter((item) => item.total > 0).length,
total: providerStats.length,
})}
</p>
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Free
<span className="size-2 rounded-full bg-green-500" /> {tc("free")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-blue-500" /> OAuth
<span className="size-2 rounded-full bg-blue-500" /> {t("oauthLabel")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> API Key
<span className="size-2 rounded-full bg-amber-500" /> {t("apiKeyLabel")}
</span>
</div>
<Link
@@ -262,7 +268,7 @@ export default function HomePageClient({ machineId }) {
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[14px]">settings</span>
Manage
{tc("manage")}
</Link>
</div>
</div>
@@ -297,14 +303,16 @@ HomePageClient.propTypes = {
function ProviderOverviewCard({ item, metrics, onClick }) {
const [imgError, setImgError] = useState(false);
const t = useTranslations("home");
const tc = useTranslations("common");
const statusVariant =
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
const authTypeConfig = {
free: { color: "bg-green-500", label: "Free" },
oauth: { color: "bg-blue-500", label: "OAuth" },
apikey: { color: "bg-amber-500", label: "API Key" },
free: { color: "bg-green-500", label: tc("free") },
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },
};
const authInfo = authTypeConfig[item.authType] || authTypeConfig.apikey;
@@ -348,14 +356,14 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
</div>
<p className={`text-xs ${statusVariant}`}>
{item.total === 0
? "Not configured"
: `${item.connected} active · ${item.errors} error`}
? tc("notConfigured")
: t("activeError", { active: item.connected, errors: item.errors })}
</p>
{metrics && metrics.totalRequests > 0 && (
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-text-muted">
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
{metrics.totalRequests} reqs
{t("requestsShort", { count: metrics.totalRequests })}
</span>
<span className="text-[10px] text-text-muted">{metrics.successRate}%</span>
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
@@ -365,7 +373,7 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
<div className="text-right shrink-0">
<p className="text-xs font-medium text-text-main">{item.modelCount}</p>
<p className="text-[10px] text-text-muted">models</p>
<p className="text-[10px] text-text-muted">{tc("models")}</p>
</div>
</div>
</button>
@@ -401,6 +409,9 @@ function ProviderModelsModal({ provider, models, onClose }) {
const [copiedModel, setCopiedModel] = useState(null);
const notify = useNotificationStore();
const router = useRouter();
const t = useTranslations("home");
const tc = useTranslations("common");
const ts = useTranslations("sidebar");
const navigateTo = (path) => {
onClose();
@@ -410,20 +421,29 @@ function ProviderModelsModal({ provider, models, onClose }) {
const handleCopy = (text) => {
navigator.clipboard.writeText(text);
setCopiedModel(text);
notify.success(`Copied: ${text}`);
notify.success(t("copiedModel", { model: text }));
setTimeout(() => setCopiedModel(null), 2000);
};
return (
<Modal isOpen={true} title={`${provider.provider.name} — Models`} onClose={onClose}>
<Modal
isOpen={true}
title={t("providerModelsTitle", { provider: provider.provider.name })}
onClose={onClose}
>
<div className="flex flex-col gap-3">
{/* Summary */}
<div className="flex items-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined text-[16px]">token</span>
{models.length} model{models.length !== 1 ? "s" : ""} available
{models.length === 1
? t("modelAvailable", { count: models.length })
: t("modelsAvailable", { count: models.length })}
{provider.total > 0 && (
<span className="ml-auto text-xs text-green-500">
{provider.connected} connection{provider.connected !== 1 ? "s" : ""} active
{" "}
{provider.connected === 1
? t("connectionsActive", { count: provider.connected })
: t("connectionsActivePlural", { count: provider.connected })}
</span>
)}
</div>
@@ -433,15 +453,9 @@ function ProviderModelsModal({ provider, models, onClose }) {
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
search_off
</span>
<p className="text-sm text-text-muted">No models available for this provider.</p>
<p className="text-sm text-text-muted">{t("noModelsAvailable")}</p>
<p className="text-xs text-text-muted mt-1">
Configure a connection first in{" "}
<button
onClick={() => navigateTo("/dashboard/providers")}
className="text-primary hover:underline cursor-pointer"
>
Providers
</button>
{t("configureFirst", { providers: ts("providers") })}
</p>
</div>
) : (
@@ -454,13 +468,15 @@ function ProviderModelsModal({ provider, models, onClose }) {
<div className="min-w-0 flex-1">
<p className="font-mono text-sm text-text-main truncate">{m.fullModel}</p>
{m.alias !== m.model && (
<p className="text-[10px] text-text-muted">alias: {m.alias}</p>
<p className="text-[10px] text-text-muted">
{t("aliasLabel")}: {m.alias}
</p>
)}
</div>
<button
onClick={() => handleCopy(m.fullModel)}
className="shrink-0 ml-2 p-1.5 rounded-lg text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors opacity-0 group-hover:opacity-100"
title="Copy model name"
title={t("copyModelName")}
>
<span className="material-symbols-outlined text-[14px]">
{copiedModel === m.fullModel ? "check" : "content_copy"}
@@ -481,10 +497,10 @@ function ProviderModelsModal({ provider, models, onClose }) {
className="flex-1"
>
<span className="material-symbols-outlined text-[14px] mr-1">settings</span>
Configure Provider
{t("configureProvider")}
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
Close
{tc("close")}
</Button>
</div>
</div>

View File

@@ -3,15 +3,15 @@
import { useState, Suspense } from "react";
import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components";
import EvalsTab from "../usage/components/EvalsTab";
import { useTranslations } from "next-intl";
export default function AnalyticsPage() {
const [activeTab, setActiveTab] = useState("overview");
const t = useTranslations("analytics");
const tabDescriptions = {
overview:
"Monitor your API usage patterns, token consumption, costs, and activity trends across all providers and models.",
evals:
"Run evaluation suites to test and validate your LLM endpoints. Compare model quality, detect regressions, and benchmark latency.",
overview: t("overviewDescription"),
evals: t("evalsDescription"),
};
return (
@@ -20,15 +20,15 @@ export default function AnalyticsPage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">analytics</span>
Analytics
{t("title")}
</h1>
<p className="text-sm text-text-muted mt-1">{tabDescriptions[activeTab]}</p>
</div>
<SegmentedControl
options={[
{ value: "overview", label: "Overview" },
{ value: "evals", label: "Evals" },
{ value: "overview", label: t("overview") },
{ value: "evals", label: t("evals") },
]}
value={activeTab}
onChange={setActiveTab}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import ApiManagerPageClient from "./ApiManagerPageClient";
export default function ApiManagerPage() {
return <ApiManagerPageClient />;
}

View File

@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface AuditEntry {
id: number;
@@ -22,6 +23,8 @@ interface AuditEntry {
const PAGE_SIZE = 25;
export default function AuditLogPage() {
const t = useTranslations("auditLog");
const tc = useTranslations("common");
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -47,11 +50,11 @@ export default function AuditLogPage() {
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
setError(err.message || t("failedFetchAuditLog"));
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
}, [actionFilter, actorFilter, offset, t]);
useEffect(() => {
fetchEntries();
@@ -87,20 +90,16 @@ export default function AuditLogPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">
Audit Log
</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
<h1 className="text-2xl font-bold text-[var(--color-text-main)]">{t("title")}</h1>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("description")}</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
aria-label={t("refreshAuditLogAria")}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
{loading ? tc("loading") : tc("refresh")}
</button>
</div>
@@ -108,31 +107,31 @@ export default function AuditLogPage() {
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
aria-label={t("filterEntriesAria")}
>
<input
type="text"
placeholder="Filter by action..."
placeholder={t("filterByAction")}
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
aria-label={t("filterByActionTypeAria")}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder="Filter by actor..."
placeholder={t("filterByActor")}
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
aria-label={t("filterByActorAria")}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
{tc("search")}
</button>
</div>
@@ -148,26 +147,26 @@ export default function AuditLogPage() {
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
{t("timestamp")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
{t("action")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
{t("actor")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
{t("target")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
{tc("details")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
IP
{t("ipAddress")}
</th>
</tr>
</thead>
@@ -175,7 +174,7 @@ export default function AuditLogPage() {
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
{t("noEntries")}
</td>
</tr>
) : (
@@ -194,17 +193,15 @@ export default function AuditLogPage() {
{entry.action}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">
{entry.actor}
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
{entry.target || t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
{entry.details ? JSON.stringify(entry.details) : t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
{entry.ip_address || t("notAvailable")}
</td>
</tr>
))
@@ -216,7 +213,7 @@ export default function AuditLogPage() {
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
{t("showing", { count: entries.length, offset })}
</p>
<div className="flex gap-2">
<button
@@ -224,14 +221,14 @@ export default function AuditLogPage() {
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
{t("previous")}
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
{tc("next")}
</button>
</div>
</div>

View File

@@ -18,10 +18,12 @@ import {
DefaultToolCard,
AntigravityToolCard,
} from "./components";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function CLIToolsPageClient({ machineId }) {
const t = useTranslations("cliTools");
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedTool, setExpandedTool] = useState(null);
@@ -71,13 +73,17 @@ export default function CLIToolsPageClient({ machineId }) {
const fetchToolStatuses = async () => {
try {
const res = await fetch("/api/cli-tools/status");
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s client timeout
const res = await fetch("/api/cli-tools/status", { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
setToolStatuses(data || {});
}
} catch (error) {
console.log("Error fetching CLI tool statuses:", error);
// Timeout or network error — proceed without statuses
console.log("CLI tool status check timed out or failed:", error);
} finally {
setStatusesLoaded(true);
}
@@ -278,11 +284,9 @@ export default function CLIToolsPageClient({ machineId }) {
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div>
<p className="font-medium text-yellow-600 dark:text-yellow-400">
No active providers
</p>
<p className="text-sm text-text-muted">
Please add and connect providers first to configure CLI tools.
{t("noActiveProviders")}
</p>
<p className="text-sm text-text-muted">{t("noActiveProvidersDesc")}</p>
</div>
</div>
</Card>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
export default function AntigravityToolCard({
tool,
@@ -14,6 +15,7 @@ export default function AntigravityToolCard({
hasActiveProviders,
cloudEnabled,
}) {
const t = useTranslations("cliTools");
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
@@ -104,12 +106,12 @@ export default function AntigravityToolCard({
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "MITM started" });
setMessage({ type: "success", text: t("mitmStarted") });
setShowPasswordModal(false);
setSudoPassword("");
fetchStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to start" });
setMessage({ type: "error", text: data.error || t("failedStart") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -130,12 +132,12 @@ export default function AntigravityToolCard({
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "MITM stopped" });
setMessage({ type: "success", text: t("mitmStopped") });
setShowPasswordModal(false);
setSudoPassword("");
fetchStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to stop" });
setMessage({ type: "error", text: data.error || t("failedStop") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -146,7 +148,7 @@ export default function AntigravityToolCard({
const handleConfirmPassword = () => {
if (!sudoPassword.trim()) {
setMessage({ type: "error", text: "Sudo password is required" });
setMessage({ type: "error", text: t("sudoPasswordRequiredError") });
return;
}
if (status?.running) {
@@ -190,10 +192,10 @@ export default function AntigravityToolCard({
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to save mappings");
throw new Error(data.error || t("failedSaveMappings"));
}
setMessage({ type: "success", text: "Mappings saved!" });
setMessage({ type: "success", text: t("mappingsSaved") });
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
@@ -225,15 +227,15 @@ export default function AntigravityToolCard({
<h3 className="font-medium text-sm">{tool.name}</h3>
{isRunning ? (
<Badge variant="success" size="sm">
Active
{t("active")}
</Badge>
) : (
<Badge variant="default" size="sm">
Inactive
{t("inactive")}
</Badge>
)}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.antigravity")}</p>
</div>
</div>
<span
@@ -254,7 +256,7 @@ export default function AntigravityToolCard({
className="px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 font-medium text-sm flex items-center gap-2 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]">stop_circle</span>
Stop MITM
{t("stopMitm")}
</button>
) : (
<button
@@ -263,7 +265,7 @@ export default function AntigravityToolCard({
className="px-4 py-2 rounded-lg bg-primary/10 border border-primary/30 text-primary font-medium text-sm flex items-center gap-2 hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[18px]">play_circle</span>
Start MITM
{t("startMitm")}
</button>
)}
</div>
@@ -280,7 +282,7 @@ export default function AntigravityToolCard({
<>
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -299,9 +301,7 @@ export default function AntigravityToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -318,7 +318,7 @@ export default function AntigravityToolCard({
type="text"
value={modelMappings[model.alias] || ""}
onChange={(e) => handleModelMappingChange(model.alias, e.target.value)}
placeholder="provider/model-id"
placeholder={t("modelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -326,13 +326,13 @@ export default function AntigravityToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
{t("select")}
</button>
{modelMappings[model.alias] && (
<button
onClick={() => handleModelMappingChange(model.alias, "")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -348,7 +348,7 @@ export default function AntigravityToolCard({
disabled={loading || Object.keys(modelMappings).length === 0}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
Save Mappings
{t("saveMappings")}
</Button>
</div>
</>
@@ -358,19 +358,19 @@ export default function AntigravityToolCard({
{!isRunning && (
<div className="flex flex-col gap-1.5 px-1">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">How it works:</span> Intercepts
Antigravity traffic via DNS redirect, letting you reroute models through OmniRoute.
<span className="font-medium text-text-main">{t("howItWorks")}</span>{" "}
{t("antigravityHowWorksDesc")}
</p>
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
<span>1. Generates SSL cert & adds to system keychain</span>
<span>{t("antigravityStep1")}</span>
<span>
2. Redirects{" "}
{t("antigravityStep2Prefix")}{" "}
<code className="text-[10px] bg-surface px-1 rounded">
daily-cloudcode-pa.googleapis.com
</code>{" "}
localhost
{t("antigravityStep2Suffix")}
</span>
<span>3. Maps Antigravity models to any provider via OmniRoute</span>
<span>{t("antigravityStep3")}</span>
</div>
</div>
)}
@@ -385,20 +385,18 @@ export default function AntigravityToolCard({
setSudoPassword("");
setMessage(null);
}}
title="Sudo Password Required"
title={t("sudoPasswordRequiredTitle")}
size="sm"
>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
<p className="text-xs text-text-muted">
Required for SSL certificate and DNS configuration
</p>
<p className="text-xs text-text-muted">{t("sudoPasswordHint")}</p>
</div>
<Input
type="password"
placeholder="Enter sudo password"
placeholder={t("enterSudoPassword")}
value={sudoPassword}
onChange={(e) => setSudoPassword(e.target.value)}
onKeyDown={(e) => {
@@ -428,10 +426,10 @@ export default function AntigravityToolCard({
}}
disabled={loading}
>
Cancel
{t("cancel")}
</Button>
<Button variant="primary" size="sm" onClick={handleConfirmPassword} loading={loading}>
Confirm
{t("confirm")}
</Button>
</div>
</div>
@@ -444,7 +442,7 @@ export default function AntigravityToolCard({
onSelect={handleModelSelect}
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
title={`Select model for ${currentEditingAlias}`}
title={t("selectModelForAlias", { alias: currentEditingAlias || "" })}
/>
</Card>
);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -21,6 +22,7 @@ export default function ClaudeToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [claudeStatus, setClaudeStatus] = useState(null);
const [checkingClaude, setCheckingClaude] = useState(false);
const [applying, setApplying] = useState(false);
@@ -151,14 +153,14 @@ export default function ClaudeToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
setClaudeStatus((prev) => ({
...prev,
hasBackup: true,
settings: { ...prev?.settings, env },
}));
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -174,13 +176,13 @@ export default function ClaudeToolCard({
const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
tool.defaultModels.forEach((model) =>
onModelMappingChange(model.alias, model.defaultValue || "")
);
setSelectedApiKey("");
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -242,11 +244,11 @@ export default function ClaudeToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkClaudeStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -281,7 +283,7 @@ export default function ClaudeToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.claude")}</p>
</div>
</div>
<span
@@ -296,7 +298,7 @@ export default function ClaudeToolCard({
{checkingClaude && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude CLI...</span>
<span>{t("checkingCli", { tool: "Claude" })}</span>
</div>
)}
@@ -307,13 +309,16 @@ export default function ClaudeToolCard({
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{claudeStatus.installed
? "Claude CLI not runnable"
: "Claude CLI not installed"}
? t("cliNotRunnable", { tool: "Claude" })
: t("cliNotInstalled", { tool: "Claude" })}
</p>
<p className="text-sm text-text-muted">
{claudeStatus.installed
? `Claude CLI was found but failed runtime healthcheck${claudeStatus.reason ? ` (${claudeStatus.reason})` : ""}.`
: "Please install Claude CLI to use this feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Claude",
reason: claudeStatus.reason ? ` (${claudeStatus.reason})` : "",
})
: t("installCliPrompt", { tool: "Claude" })}
</p>
</div>
<Button
@@ -324,23 +329,23 @@ export default function ClaudeToolCard({
<span className="material-symbols-outlined text-[18px] mr-1">
{showInstallGuide ? "expand_less" : "help"}
</span>
{showInstallGuide ? "Hide" : "How to Install"}
{showInstallGuide ? t("hide") : t("howToInstall")}
</Button>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<h4 className="font-medium mb-3">{t("installationGuide")}</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<p className="text-text-muted mb-1">{t("platforms")}</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">
npm install -g @anthropic-ai/claude-code
</code>
</div>
<p className="text-text-muted">
After installation, run{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to
verify.
{t("afterInstallationRun")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code>{" "}
{t("toVerify")}
</p>
</div>
</div>
@@ -355,7 +360,7 @@ export default function ClaudeToolCard({
{claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -369,7 +374,7 @@ export default function ClaudeToolCard({
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -378,14 +383,14 @@ export default function ClaudeToolCard({
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -395,7 +400,7 @@ export default function ClaudeToolCard({
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -414,9 +419,7 @@ export default function ClaudeToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -434,7 +437,7 @@ export default function ClaudeToolCard({
type="text"
value={modelMappings[model.alias] || ""}
onChange={(e) => onModelMappingChange(model.alias, e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -442,13 +445,13 @@ export default function ClaudeToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{modelMappings[model.alias] && (
<button
onClick={() => onModelMappingChange(model.alias, "")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -476,7 +479,8 @@ export default function ClaudeToolCard({
disabled={!hasActiveProviders}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -485,11 +489,12 @@ export default function ClaudeToolCard({
disabled={!claudeStatus?.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -500,7 +505,8 @@ export default function ClaudeToolCard({
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -510,12 +516,10 @@ export default function ClaudeToolCard({
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -537,7 +541,7 @@ export default function ClaudeToolCard({
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -557,13 +561,13 @@ export default function ClaudeToolCard({
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
activeProviders={activeProviders}
modelAliases={modelAliases}
title={`Select model for ${currentEditingAlias}`}
title={t("selectModelForAlias", { alias: currentEditingAlias || "" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Claude CLI - Manual Configuration"
title={t("claudeManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -1,4 +1,5 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
/**
* Shared status badge for CLI tool cards.
@@ -7,28 +8,31 @@
* Optionally shows last-configured relative timestamp.
*/
function formatRelativeTime(isoDate: string): string {
function formatRelativeTime(
isoDate: string,
t: (key: string, values?: Record<string, unknown>) => string
): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
if (diffMs < 0) return "just now";
if (diffMs < 0) return t("justNow");
const seconds = Math.floor(diffMs / 1000);
if (seconds < 60) return "just now";
if (seconds < 60) return t("justNow");
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
if (minutes < 60) return t("minutesAgoShort", { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
if (hours < 24) return t("hoursAgoShort", { count: hours });
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
if (days < 30) return t("daysAgoShort", { count: days });
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
if (months < 12) return t("monthsAgoShort", { count: months });
return `${Math.floor(months / 12)}y ago`;
return t("yearsAgoShort", { count: Math.floor(months / 12) });
}
export default function CliStatusBadge({
@@ -36,6 +40,8 @@ export default function CliStatusBadge({
batchStatus,
lastConfiguredAt = null,
}) {
const t = useTranslations("cliTools");
const locale = useLocale();
// Determine badge from effectiveConfigStatus or batchStatus
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
@@ -43,27 +49,27 @@ export default function CliStatusBadge({
configured: {
dotClass: "bg-green-500",
badgeClass: "bg-green-500/10 text-green-600 dark:text-green-400",
text: "Configured",
text: t("configured"),
},
not_configured: {
dotClass: "bg-yellow-500",
badgeClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
text: "Not configured",
text: t("notConfigured"),
},
not_installed: {
dotClass: "bg-zinc-400 dark:bg-zinc-500",
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
text: "Not installed",
text: t("notInstalled"),
},
other: {
dotClass: "bg-blue-500",
badgeClass: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
text: "Custom",
text: t("custom"),
},
unknown: {
dotClass: "bg-zinc-400 dark:bg-zinc-500",
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
text: "Unknown",
text: t("unknown"),
},
};
@@ -82,15 +88,15 @@ export default function CliStatusBadge({
{lastConfiguredAt ? (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted"
title={`Last saved: ${new Date(lastConfiguredAt).toLocaleString()}`}
title={t("lastSavedAt", { date: new Date(lastConfiguredAt).toLocaleString(locale) })}
>
<span className="material-symbols-outlined text-[12px]">schedule</span>
{formatRelativeTime(lastConfiguredAt)}
{formatRelativeTime(lastConfiguredAt, t)}
</span>
) : status && status !== "not_installed" ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-text-muted">
<span className="material-symbols-outlined text-[12px]">schedule</span>
Never
{t("never")}
</span>
) : null}
</>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function ClineToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [clineStatus, setClineStatus] = useState(null);
const [checkingCline, setCheckingCline] = useState(false);
const [applying, setApplying] = useState(false);
@@ -109,12 +111,12 @@ export default function ClineToolCard({
body: JSON.stringify({ tool: "cline", backupId }),
});
if (res.ok) {
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
setMessage({ type: "success", text: t("backupRestoredReloading") });
await checkClineStatus();
await fetchBackups();
} else {
const data = await res.json();
setMessage({ type: "error", text: data.error || "Failed to restore backup" });
setMessage({ type: "error", text: data.error || t("failedRestoreBackup") });
}
} catch (e) {
setMessage({ type: "error", text: e.message });
@@ -161,11 +163,11 @@ export default function ClineToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Applied!" });
setMessage({ type: "success", text: data.message || t("applied") });
await checkClineStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -181,13 +183,13 @@ export default function ClineToolCard({
const res = await fetch("/api/cli-tools/cline-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Reset!" });
setMessage({ type: "success", text: data.message || t("resetDone") });
setSelectedModel("");
hasInitializedModel.current = false;
await checkClineStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -240,7 +242,7 @@ export default function ClineToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.cline")}</p>
</div>
</div>
<span
@@ -257,7 +259,7 @@ export default function ClineToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Checking Cline CLI...</span>
<span>{t("checkingCli", { tool: "Cline" })}</span>
</div>
)}
@@ -273,14 +275,14 @@ export default function ClineToolCard({
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">
{cliReady
? "Cline CLI detected and ready"
? t("cliDetectedReady", { tool: "Cline" })
: clineStatus.installed
? "Cline CLI installed but not runnable"
: "Cline CLI not detected"}
? t("cliNotRunnable", { tool: "Cline" })
: t("cliNotDetected", { tool: "Cline" })}
</p>
{clineStatus.commandPath && (
<p className="text-xs text-text-muted">
Binary:{" "}
{t("binary")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{clineStatus.commandPath}
</code>
@@ -288,7 +290,7 @@ export default function ClineToolCard({
)}
{clineStatus.globalStatePath && (
<p className="text-xs text-text-muted">
Config:{" "}
{t("configPathShort")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{clineStatus.globalStatePath}
</code>
@@ -307,10 +309,10 @@ export default function ClineToolCard({
</span>
<div className="flex flex-col gap-1">
<p className="text-sm text-green-700 dark:text-green-300">
OmniRoute is configured as OpenAI-compatible provider
{t("omnirouteConfiguredOpenAiCompatible")}
</p>
<p className="text-xs text-text-muted">
Provider: <strong>openai</strong> Model:{" "}
{t("provider")}: <strong>openai</strong> {t("model")}:{" "}
<strong>{clineStatus.settings?.openAiModelId || "—"}</strong>
</p>
</div>
@@ -319,13 +321,13 @@ export default function ClineToolCard({
{/* Model selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">Model</label>
<label className="text-sm text-text-muted">{t("model")}</label>
<div className="flex items-center gap-2">
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<Button
@@ -334,7 +336,7 @@ export default function ClineToolCard({
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
>
Select
{t("select")}
</Button>
<Button
variant="ghost"
@@ -348,7 +350,7 @@ export default function ClineToolCard({
{/* API Key selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">API Key</label>
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKey}
@@ -363,7 +365,7 @@ export default function ClineToolCard({
</select>
) : (
<p className="text-sm text-text-muted">
{cloudEnabled ? "No API keys available" : "Using default: sk_omniroute"}
{cloudEnabled ? t("noApiKeysAvailable") : t("usingDefaultOmniroute")}
</p>
)}
</div>
@@ -378,14 +380,14 @@ export default function ClineToolCard({
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{configStatus === "configured" ? "Update Config" : "Apply Config"}
{configStatus === "configured" ? t("updateConfig") : t("applyConfig")}
</Button>
{configStatus === "configured" && (
<Button variant="outline" size="sm" onClick={handleReset} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">
restart_alt
</span>
Reset
{t("reset")}
</Button>
)}
</div>
@@ -414,7 +416,7 @@ export default function ClineToolCard({
chevron_right
</span>
<span className="material-symbols-outlined text-[16px]">backup</span>
Backups {backups.length > 0 && `(${backups.length})`}
{t("backups")} {backups.length > 0 && `(${backups.length})`}
</button>
{showBackups && backups.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5 pl-6">
@@ -435,14 +437,14 @@ export default function ClineToolCard({
onClick={() => handleRestoreBackup(b.id)}
loading={restoringBackup === b.id}
>
Restore
{t("restore")}
</Button>
</div>
))}
</div>
)}
{showBackups && backups.length === 0 && (
<p className="mt-2 pl-6 text-xs text-text-muted">No backups available.</p>
<p className="mt-2 pl-6 text-xs text-text-muted">{t("noBackupsAvailable")}</p>
)}
</div>
</>
@@ -458,13 +460,13 @@ export default function ClineToolCard({
onSelect={handleSelectModel}
selectedModel={selectedModel}
activeProviders={activeProviders}
title="Select Model for Cline"
title={t("selectModelForTool", { tool: "Cline" })}
/>
{showManualConfigModal && (
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Cline Manual Configuration"
title={t("clineManualConfiguration")}
{...({
onApply: handleManualConfig,
currentConfig: {

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
export default function CodexToolCard({
tool,
@@ -16,6 +17,7 @@ export default function CodexToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [codexStatus, setCodexStatus] = useState(null);
const [checkingCodex, setCheckingCodex] = useState(false);
const [applying, setApplying] = useState(false);
@@ -132,10 +134,10 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -151,11 +153,11 @@ export default function CodexToolCard({
const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -192,11 +194,11 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: `Profile "${newProfileName}" saved!` });
setMessage({ type: "success", text: t("profileSaved", { name: newProfileName }) });
setNewProfileName("");
fetchProfiles();
} else {
setMessage({ type: "error", text: data.error || "Failed to save profile" });
setMessage({ type: "error", text: data.error || t("failedSaveProfile") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -216,11 +218,11 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Profile activated!" });
setMessage({ type: "success", text: data.message || t("profileActivated") });
checkCodexStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to activate profile" });
setMessage({ type: "error", text: data.error || t("failedActivateProfile") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -264,11 +266,11 @@ export default function CodexToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkCodexStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -341,7 +343,7 @@ wire_api = "responses"
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.codex")}</p>
</div>
</div>
<span
@@ -356,7 +358,7 @@ wire_api = "responses"
{checkingCodex && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Codex CLI...</span>
<span>{t("checkingCli", { tool: "Codex" })}</span>
</div>
)}
@@ -366,12 +368,17 @@ wire_api = "responses"
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{codexStatus.installed ? "Codex CLI not runnable" : "Codex CLI not installed"}
{codexStatus.installed
? t("cliNotRunnable", { tool: "Codex" })
: t("cliNotInstalled", { tool: "Codex" })}
</p>
<p className="text-sm text-text-muted">
{codexStatus.installed
? `Codex CLI was found but failed runtime healthcheck${codexStatus.reason ? ` (${codexStatus.reason})` : ""}.`
: "Please install Codex CLI to use auto-apply feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Codex",
reason: codexStatus.reason ? ` (${codexStatus.reason})` : "",
})
: t("installCodexPrompt")}
</p>
</div>
<Button
@@ -382,35 +389,35 @@ wire_api = "responses"
<span className="material-symbols-outlined text-[18px] mr-1">
{showInstallGuide ? "expand_less" : "help"}
</span>
{showInstallGuide ? "Hide" : "How to Install"}
{showInstallGuide ? t("hide") : t("howToInstall")}
</Button>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<h4 className="font-medium mb-3">{t("installationGuide")}</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<p className="text-text-muted mb-1">{t("platforms")}</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">
npm install -g @openai/codex
</code>
</div>
<p className="text-text-muted">
After installation, run{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to
verify.
{t("afterInstallationRun")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code>{" "}
{t("toVerify")}
</p>
<div className="pt-2 border-t border-border">
<p className="text-text-muted text-xs">
Codex uses{" "}
{t("codexAuthNotePrefix")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">
~/.codex/auth.json
</code>{" "}
with{" "}
{t("codexAuthNoteMiddle")}{" "}
<code className="px-1 bg-black/5 dark:bg-white/5 rounded">
OPENAI_API_KEY
</code>
. Click &quot;Apply&quot; to auto-configure.
. {t("codexAuthNoteSuffix")}
</p>
</div>
</div>
@@ -430,7 +437,7 @@ wire_api = "responses"
return currentBaseUrl ? (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -445,7 +452,7 @@ wire_api = "responses"
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -454,14 +461,14 @@ wire_api = "responses"
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== `${baseUrl}/v1` && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -471,7 +478,7 @@ wire_api = "responses"
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -490,9 +497,7 @@ wire_api = "responses"
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -500,7 +505,7 @@ wire_api = "responses"
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Model
{t("model")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -509,7 +514,7 @@ wire_api = "responses"
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -517,13 +522,13 @@ wire_api = "responses"
disabled={!activeProviders?.length}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -550,7 +555,8 @@ wire_api = "responses"
disabled={!selectedApiKey || !selectedModel}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -559,11 +565,12 @@ wire_api = "responses"
disabled={!codexStatus.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -577,7 +584,7 @@ wire_api = "responses"
<span className="material-symbols-outlined text-[14px] mr-1">
manage_accounts
</span>
Profiles
{t("profiles")}
</Button>
<Button
variant="ghost"
@@ -587,7 +594,8 @@ wire_api = "responses"
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -597,12 +605,10 @@ wire_api = "responses"
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">manage_accounts</span>
Saved Profiles
{t("savedProfiles")}
</h4>
{profiles.length === 0 ? (
<p className="text-xs text-text-muted">
No profiles saved yet. Save current config as a profile below.
</p>
<p className="text-xs text-text-muted">{t("noProfilesYet")}</p>
) : (
<div className="space-y-1.5 mb-3">
{profiles.map((p) => (
@@ -625,12 +631,12 @@ wire_api = "responses"
disabled={activatingProfile === p.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{activatingProfile === p.id ? "..." : "Activate"}
{activatingProfile === p.id ? "..." : t("activate")}
</button>
<button
onClick={() => handleDeleteProfile(p.id)}
className="p-0.5 text-text-muted hover:text-red-500 transition-colors"
title="Delete profile"
title={t("deleteProfile")}
>
<span className="material-symbols-outlined text-[14px]">delete</span>
</button>
@@ -643,7 +649,7 @@ wire_api = "responses"
type="text"
value={newProfileName}
onChange={(e) => setNewProfileName(e.target.value)}
placeholder="Profile name (e.g. Personal Account)"
placeholder={t("profileNamePlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
onKeyDown={(e) => e.key === "Enter" && handleSaveProfile()}
/>
@@ -654,8 +660,8 @@ wire_api = "responses"
disabled={!newProfileName.trim()}
loading={savingProfile}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Save
Current
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("saveCurrent")}
</Button>
</div>
</div>
@@ -666,12 +672,10 @@ wire_api = "responses"
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -693,7 +697,7 @@ wire_api = "responses"
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -713,13 +717,13 @@ wire_api = "responses"
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
title={t("selectModelForTool", { tool: "Codex" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Codex CLI - Manual Configuration"
title={t("codexManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
export default function DefaultToolCard({
toolId,
@@ -15,6 +16,17 @@ export default function DefaultToolCard({
cloudEnabled = false,
batchStatus,
}) {
const t = useTranslations("cliTools");
const translateOrFallback = useCallback(
(key, fallback, values) => {
try {
return t(key, values);
} catch {
return fallback;
}
},
[t]
);
const [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false);
const [modelValue, setModelValue] = useState("");
@@ -65,7 +77,7 @@ export default function DefaultToolCard({
fetch(`/api/cli-tools/runtime/${toolId}`)
.then((res) => res.json())
.then((data) => setRuntimeStatus(data))
.catch((error) => setRuntimeStatus({ error: error?.message || "runtime_check_failed" }));
.catch((error) => setRuntimeStatus({ error: error?.message || t("runtimeCheckFailed") }));
}, [isExpanded, runtimeStatus, toolId]);
const replaceVars = (text) => {
@@ -74,7 +86,7 @@ export default function DefaultToolCard({
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "your-api-key";
: t("yourApiKeyPlaceholder");
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
@@ -84,7 +96,7 @@ export default function DefaultToolCard({
return text
.replace(/\{\{baseUrl\}\}/g, baseUrlWithV1)
.replace(/\{\{apiKey\}\}/g, keyToUse)
.replace(/\{\{model\}\}/g, modelValue || "provider/model-id");
.replace(/\{\{model\}\}/g, modelValue || t("modelPlaceholder"));
};
const handleCopy = async (text, field) => {
@@ -128,9 +140,9 @@ export default function DefaultToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Configuration saved!" });
setMessage({ type: "success", text: data.message || t("configurationSaved") });
} else {
setMessage({ type: "error", text: data.error || "Failed to save" });
setMessage({ type: "error", text: data.error || t("failedToSave") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -169,7 +181,7 @@ export default function DefaultToolCard({
</>
) : (
<span className="text-sm text-text-muted">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_omniroute"}
{cloudEnabled ? t("noApiKeysCreateOne") : "sk_omniroute"}
</span>
)}
</div>
@@ -183,7 +195,7 @@ export default function DefaultToolCard({
type="text"
value={modelValue}
onChange={(e) => handleModelChange(e.target.value)}
placeholder="provider/model-id"
placeholder={t("modelPlaceholder")}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -195,7 +207,7 @@ export default function DefaultToolCard({
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
{t("selectModel")}
</button>
{modelValue && (
<>
@@ -210,7 +222,7 @@ export default function DefaultToolCard({
<button
onClick={() => handleModelChange("")}
className="p-2 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -251,7 +263,9 @@ export default function DefaultToolCard({
return (
<div key={index} className={`flex items-start gap-3 p-3 rounded-lg border ${bgClass}`}>
<span className={`material-symbols-outlined text-lg ${iconClass}`}>{icon}</span>
<p className={`text-sm ${textClass}`}>{note.text}</p>
<p className={`text-sm ${textClass}`}>
{translateOrFallback(`guides.${toolId}.notes.${index}`, note.text)}
</p>
</div>
);
})}
@@ -265,7 +279,7 @@ export default function DefaultToolCard({
};
const renderGuideSteps = () => {
if (!tool.guideSteps) return <p className="text-text-muted text-sm">Coming soon...</p>;
if (!tool.guideSteps) return <p className="text-text-muted text-sm">{t("comingSoon")}</p>;
return (
<div className="flex flex-col gap-4">
@@ -274,7 +288,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Checking runtime...</span>
<span>{t("checkingRuntime")}</span>
</div>
)}
{!checkingRuntime && runtimeStatus && !runtimeStatus.error && (
@@ -289,16 +303,18 @@ export default function DefaultToolCard({
<div className="flex flex-col gap-1">
<p className="text-sm text-blue-700 dark:text-blue-300">
{runtimeStatus.reason === "not_required"
? "Guide-only integration: no local binary required"
? t("guideOnlyIntegration")
: runtimeStatus.installed && runtimeStatus.runnable
? "CLI runtime detected and healthy"
? t("cliRuntimeDetected")
: runtimeStatus.installed
? `CLI found but not runnable${runtimeStatus.reason ? ` (${runtimeStatus.reason})` : ""}`
: "CLI runtime not detected"}
? t("cliFoundNotRunnable", {
reason: runtimeStatus.reason ? `: ${runtimeStatus.reason}` : "",
})
: t("cliRuntimeNotDetected")}
</p>
{runtimeStatus.commandPath && (
<p className="text-xs text-text-muted">
Binary:{" "}
{t("binary")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{runtimeStatus.commandPath}
</code>
@@ -306,7 +322,7 @@ export default function DefaultToolCard({
)}
{runtimeStatus.configPath && (
<p className="text-xs text-text-muted">
Config path:{" "}
{t("configPath")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{runtimeStatus.configPath}
</code>
@@ -319,7 +335,7 @@ export default function DefaultToolCard({
<div className="flex items-start gap-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<span className="material-symbols-outlined text-red-500 text-lg">error</span>
<p className="text-sm text-red-600 dark:text-red-400">
Failed to check runtime status.
{t("failedCheckRuntimeStatus")}
</p>
</div>
)}
@@ -334,8 +350,14 @@ export default function DefaultToolCard({
{item.step}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-text">{item.title}</p>
{item.desc && <p className="text-sm text-text-muted mt-0.5">{item.desc}</p>}
<p className="font-medium text-text">
{translateOrFallback(`guides.${toolId}.steps.${item.step}.title`, item.title)}
</p>
{item.desc && (
<p className="text-sm text-text-muted mt-0.5">
{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc)}
</p>
)}
{item.type === "apiKeySelector" && renderApiKeySelector()}
{item.type === "modelSelector" && renderModelSelector()}
{item.value && (
@@ -372,7 +394,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined text-sm">
{copiedField === "codeblock" ? "check" : "content_copy"}
</span>
{copiedField === "codeblock" ? "Copied!" : "Copy"}
{copiedField === "codeblock" ? t("copied") : t("copy")}
</button>
</div>
<pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto">
@@ -405,8 +427,8 @@ export default function DefaultToolCard({
disabled={!modelValue}
loading={saving}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Save
Config
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("saveConfig")}
</Button>
)}
{tool.codeBlock && (
@@ -418,7 +440,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedField === "codeblock" ? "check" : "content_copy"}
</span>
{copiedField === "codeblock" ? "Copied!" : "Copy Config"}
{copiedField === "codeblock" ? t("copied") : t("copyConfig")}
</Button>
)}
{modelValue && (
@@ -426,7 +448,7 @@ export default function DefaultToolCard({
<span className="material-symbols-outlined text-[14px] text-green-500">
check_circle
</span>
Selection saved
{t("selectionSaved")}
</span>
)}
</div>
@@ -496,7 +518,7 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500" />
Guide
{t("guide")}
</span>
);
}
@@ -504,7 +526,7 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500" />
Detected
{t("detected")}
</span>
);
}
@@ -512,7 +534,7 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400">
<span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" />
Not installed
{t("notInstalled")}
</span>
);
}
@@ -520,14 +542,16 @@ export default function DefaultToolCard({
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
<span className="size-1.5 rounded-full bg-yellow-500" />
Not ready
{t("notReady")}
</span>
);
}
return null;
})()}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">
{translateOrFallback(`toolDescriptions.${toolId}`, tool.description)}
</p>
</div>
</div>
<span
@@ -545,7 +569,7 @@ export default function DefaultToolCard({
onSelect={handleSelectModel}
selectedModel={modelValue}
activeProviders={activeProviders}
title="Select Model"
title={t("selectModel")}
/>
</Card>
);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function DroidToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [droidStatus, setDroidStatus] = useState(null);
const [checkingDroid, setCheckingDroid] = useState(false);
const [applying, setApplying] = useState(false);
@@ -137,10 +139,10 @@ export default function DroidToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -156,12 +158,12 @@ export default function DroidToolCard({
const res = await fetch("/api/cli-tools/droid-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
setSelectedApiKey("");
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -197,11 +199,11 @@ export default function DroidToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkDroidStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -274,7 +276,7 @@ export default function DroidToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.droid")}</p>
</div>
</div>
<span
@@ -289,7 +291,7 @@ export default function DroidToolCard({
{checkingDroid && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Factory Droid CLI...</span>
<span>{t("checkingCli", { tool: "Factory Droid" })}</span>
</div>
)}
@@ -299,13 +301,16 @@ export default function DroidToolCard({
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{droidStatus.installed
? "Factory Droid CLI not runnable"
: "Factory Droid CLI not installed"}
? t("cliNotRunnable", { tool: "Factory Droid" })
: t("cliNotInstalled", { tool: "Factory Droid" })}
</p>
<p className="text-sm text-text-muted">
{droidStatus.installed
? `Factory Droid CLI was found but failed runtime healthcheck${droidStatus.reason ? ` (${droidStatus.reason})` : ""}.`
: "Please install Factory Droid CLI to use this feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Factory Droid",
reason: droidStatus.reason ? ` (${droidStatus.reason})` : "",
})
: t("installCliPrompt", { tool: "Factory Droid" })}
</p>
</div>
</div>
@@ -319,7 +324,7 @@ export default function DroidToolCard({
?.baseUrl && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -336,7 +341,7 @@ export default function DroidToolCard({
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -345,14 +350,14 @@ export default function DroidToolCard({
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -362,7 +367,7 @@ export default function DroidToolCard({
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -381,9 +386,7 @@ export default function DroidToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -391,7 +394,7 @@ export default function DroidToolCard({
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Model
{t("model")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -400,7 +403,7 @@ export default function DroidToolCard({
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -408,13 +411,13 @@ export default function DroidToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -441,7 +444,8 @@ export default function DroidToolCard({
disabled={!selectedModel}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -450,11 +454,12 @@ export default function DroidToolCard({
disabled={!droidStatus?.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -465,7 +470,8 @@ export default function DroidToolCard({
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -474,12 +480,10 @@ export default function DroidToolCard({
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -501,7 +505,7 @@ export default function DroidToolCard({
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -521,13 +525,13 @@ export default function DroidToolCard({
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Factory Droid"
title={t("selectModelForTool", { tool: "Factory Droid" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Factory Droid - Manual Configuration"
title={t("droidManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function KiloToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [kiloStatus, setKiloStatus] = useState(null);
const [checkingKilo, setCheckingKilo] = useState(false);
const [applying, setApplying] = useState(false);
@@ -95,12 +97,12 @@ export default function KiloToolCard({
body: JSON.stringify({ tool: "kilo", backupId }),
});
if (res.ok) {
setMessage({ type: "success", text: "Backup restored! Reloading status..." });
setMessage({ type: "success", text: t("backupRestoredReloading") });
await checkKiloStatus();
await fetchBackups();
} else {
const data = await res.json();
setMessage({ type: "error", text: data.error || "Failed to restore backup" });
setMessage({ type: "error", text: data.error || t("failedRestoreBackup") });
}
} catch (e) {
setMessage({ type: "error", text: e.message });
@@ -147,11 +149,11 @@ export default function KiloToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Applied!" });
setMessage({ type: "success", text: data.message || t("applied") });
await checkKiloStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -167,13 +169,13 @@ export default function KiloToolCard({
const res = await fetch("/api/cli-tools/kilo-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Reset!" });
setMessage({ type: "success", text: data.message || t("resetDone") });
setSelectedModel("");
hasInitializedModel.current = false;
await checkKiloStatus();
await fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed" });
setMessage({ type: "error", text: data.error || t("failed") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -226,7 +228,7 @@ export default function KiloToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.kilo")}</p>
</div>
</div>
<span
@@ -243,7 +245,7 @@ export default function KiloToolCard({
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Checking Kilo Code CLI...</span>
<span>{t("checkingCli", { tool: "Kilo Code" })}</span>
</div>
)}
@@ -259,14 +261,14 @@ export default function KiloToolCard({
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">
{cliReady
? "Kilo Code CLI detected and ready"
? t("cliDetectedReady", { tool: "Kilo Code" })
: kiloStatus.installed
? "Kilo Code CLI installed but not runnable"
: "Kilo Code CLI not detected"}
? t("cliNotRunnable", { tool: "Kilo Code" })
: t("cliNotDetected", { tool: "Kilo Code" })}
</p>
{kiloStatus.commandPath && (
<p className="text-xs text-text-muted">
Binary:{" "}
{t("binary")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{kiloStatus.commandPath}
</code>
@@ -274,7 +276,7 @@ export default function KiloToolCard({
)}
{kiloStatus.authPath && (
<p className="text-xs text-text-muted">
Auth:{" "}
{t("auth")}:{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
{kiloStatus.authPath}
</code>
@@ -293,10 +295,11 @@ export default function KiloToolCard({
</span>
<div className="flex flex-col gap-1">
<p className="text-sm text-green-700 dark:text-green-300">
OmniRoute is configured as OpenAI-compatible provider
{t("omnirouteConfiguredOpenAiCompatible")}
</p>
<p className="text-xs text-text-muted">
Providers: <strong>{kiloStatus.settings?.auth?.join(", ") || "—"}</strong>
{t("providers")}:{" "}
<strong>{kiloStatus.settings?.auth?.join(", ") || "—"}</strong>
</p>
</div>
</div>
@@ -304,13 +307,13 @@ export default function KiloToolCard({
{/* Model selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">Model</label>
<label className="text-sm text-text-muted">{t("model")}</label>
<div className="flex items-center gap-2">
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<Button
@@ -319,7 +322,7 @@ export default function KiloToolCard({
onClick={() => setModalOpen(true)}
disabled={!hasActiveProviders}
>
Select
{t("select")}
</Button>
<Button
variant="ghost"
@@ -333,7 +336,7 @@ export default function KiloToolCard({
{/* API Key selection */}
<div className="flex flex-col gap-2">
<label className="text-sm text-text-muted">API Key</label>
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKey}
@@ -348,7 +351,7 @@ export default function KiloToolCard({
</select>
) : (
<p className="text-sm text-text-muted">
{cloudEnabled ? "No API keys available" : "Using default: sk_omniroute"}
{cloudEnabled ? t("noApiKeysAvailable") : t("usingDefaultOmniroute")}
</p>
)}
</div>
@@ -363,14 +366,14 @@ export default function KiloToolCard({
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{configStatus === "configured" ? "Update Config" : "Apply Config"}
{configStatus === "configured" ? t("updateConfig") : t("applyConfig")}
</Button>
{configStatus === "configured" && (
<Button variant="outline" size="sm" onClick={handleReset} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">
restart_alt
</span>
Reset
{t("reset")}
</Button>
)}
</div>
@@ -399,7 +402,7 @@ export default function KiloToolCard({
chevron_right
</span>
<span className="material-symbols-outlined text-[16px]">backup</span>
Backups {backups.length > 0 && `(${backups.length})`}
{t("backups")} {backups.length > 0 && `(${backups.length})`}
</button>
{showBackups && backups.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5 pl-6">
@@ -420,14 +423,14 @@ export default function KiloToolCard({
onClick={() => handleRestoreBackup(b.id)}
loading={restoringBackup === b.id}
>
Restore
{t("restore")}
</Button>
</div>
))}
</div>
)}
{showBackups && backups.length === 0 && (
<p className="mt-2 pl-6 text-xs text-text-muted">No backups available.</p>
<p className="mt-2 pl-6 text-xs text-text-muted">{t("noBackupsAvailable")}</p>
)}
</div>
</>
@@ -443,13 +446,13 @@ export default function KiloToolCard({
onSelect={handleSelectModel}
selectedModel={selectedModel}
activeProviders={activeProviders}
title="Select Model for Kilo Code"
title={t("selectModelForTool", { tool: "Kilo Code" })}
/>
{showManualConfigModal && (
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Kilo Code Manual Configuration"
title={t("kiloManualConfiguration")}
{...({
onApply: handleManualConfig,
currentConfig: {

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -19,6 +20,7 @@ export default function OpenClawToolCard({
batchStatus,
lastConfiguredAt,
}) {
const t = useTranslations("cliTools");
const [openclawStatus, setOpenclawStatus] = useState(null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
const [applying, setApplying] = useState(false);
@@ -138,10 +140,10 @@ export default function OpenClawToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setMessage({ type: "success", text: t("settingsApplied") });
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
setMessage({ type: "error", text: data.error || t("failedApplySettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -157,12 +159,12 @@ export default function OpenClawToolCard({
const res = await fetch("/api/cli-tools/openclaw-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
setSelectedApiKey("");
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -198,11 +200,11 @@ export default function OpenClawToolCard({
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Backup restored!" });
setMessage({ type: "success", text: t("backupRestored") });
checkOpenclawStatus();
fetchBackups();
} else {
setMessage({ type: "error", text: data.error || "Failed to restore" });
setMessage({ type: "error", text: data.error || t("failedRestore") });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
@@ -278,7 +280,7 @@ export default function OpenClawToolCard({
lastConfiguredAt={lastConfiguredAt}
/>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
<p className="text-xs text-text-muted truncate">{t("toolDescriptions.openclaw")}</p>
</div>
</div>
<span
@@ -293,7 +295,7 @@ export default function OpenClawToolCard({
{checkingOpenclaw && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Open Claw CLI...</span>
<span>{t("checkingCli", { tool: "Open Claw" })}</span>
</div>
)}
@@ -303,13 +305,16 @@ export default function OpenClawToolCard({
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">
{openclawStatus.installed
? "Open Claw CLI not runnable"
: "Open Claw CLI not installed"}
? t("cliNotRunnable", { tool: "Open Claw" })
: t("cliNotInstalled", { tool: "Open Claw" })}
</p>
<p className="text-sm text-text-muted">
{openclawStatus.installed
? `Open Claw CLI was found but failed runtime healthcheck${openclawStatus.reason ? ` (${openclawStatus.reason})` : ""}.`
: "Please install Open Claw CLI to use this feature."}
? t("cliFoundFailedHealthcheck", {
tool: "Open Claw",
reason: openclawStatus.reason ? ` (${openclawStatus.reason})` : "",
})
: t("installCliPrompt", { tool: "Open Claw" })}
</p>
</div>
</div>
@@ -322,7 +327,7 @@ export default function OpenClawToolCard({
{openclawStatus?.settings?.models?.providers?.["omniroute"]?.baseUrl && (
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Current
{t("current")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -336,7 +341,7 @@ export default function OpenClawToolCard({
{/* Base URL */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Base URL
{t("baseUrl")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -345,14 +350,14 @@ export default function OpenClawToolCard({
type="text"
value={getDisplayUrl()}
onChange={(e) => setCustomBaseUrl(e.target.value)}
placeholder="https://.../v1"
placeholder={t("baseUrlPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{customBaseUrl && customBaseUrl !== baseUrl && (
<button
onClick={() => setCustomBaseUrl("")}
className="p-1 text-text-muted hover:text-primary rounded transition-colors"
title="Reset to default"
title={t("resetToDefault")}
>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
</button>
@@ -362,7 +367,7 @@ export default function OpenClawToolCard({
{/* API Key */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
API Key
{t("apiKey")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -381,9 +386,7 @@ export default function OpenClawToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled
? "No API keys - Create one in Keys page"
: "sk_omniroute (default)"}
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
</span>
)}
</div>
@@ -391,7 +394,7 @@ export default function OpenClawToolCard({
{/* Model */}
<div className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">
Model
{t("model")}
</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">
arrow_forward
@@ -400,7 +403,7 @@ export default function OpenClawToolCard({
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="provider/model-id"
placeholder={t("providerModelPlaceholder")}
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
@@ -408,13 +411,13 @@ export default function OpenClawToolCard({
disabled={!hasActiveProviders}
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select Model
{t("selectModel")}
</button>
{selectedModel && (
<button
onClick={() => setSelectedModel("")}
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
@@ -441,7 +444,8 @@ export default function OpenClawToolCard({
disabled={!selectedModel}
loading={applying}
>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
{t("apply")}
</Button>
<Button
variant="outline"
@@ -450,11 +454,12 @@ export default function OpenClawToolCard({
disabled={!openclawStatus?.hasOmniRoute}
loading={restoring}
>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>
{t("reset")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)}>
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>
Manual Config
{t("manualConfig")}
</Button>
<div className="flex-1" />
<Button
@@ -465,7 +470,8 @@ export default function OpenClawToolCard({
if (!showBackups) fetchBackups();
}}
>
<span className="material-symbols-outlined text-[14px] mr-1">history</span>Backups
<span className="material-symbols-outlined text-[14px] mr-1">history</span>
{t("backups")}
{backups.length > 0 && ` (${backups.length})`}
</Button>
</div>
@@ -474,12 +480,10 @@ export default function OpenClawToolCard({
<div className="mt-2 p-3 bg-surface border border-border rounded-lg">
<h4 className="text-xs font-semibold text-text-main mb-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">history</span>
Config Backups
{t("configBackups")}
</h4>
{backups.length === 0 ? (
<p className="text-xs text-text-muted">
No backups yet. Backups are created automatically before each Apply or Reset.
</p>
<p className="text-xs text-text-muted">{t("noBackupsYet")}</p>
) : (
<div className="space-y-1.5">
{backups.map((b) => (
@@ -501,7 +505,7 @@ export default function OpenClawToolCard({
disabled={restoringBackup === b.id}
className="px-2 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium hover:bg-primary/20 transition-colors disabled:opacity-50"
>
{restoringBackup === b.id ? "..." : "Restore"}
{restoringBackup === b.id ? "..." : t("restore")}
</button>
</div>
))}
@@ -521,13 +525,13 @@ export default function OpenClawToolCard({
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Open Claw"
title={t("selectModelForTool", { tool: "Open Claw" })}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title="Open Claw - Manual Configuration"
title={t("openClawManualConfiguration")}
configs={getManualConfigs()}
/>
</Card>

View File

@@ -14,6 +14,7 @@ import {
} from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
@@ -34,6 +35,8 @@ function getModelString(entry) {
// Main Page
// ─────────────────────────────────────────────
export default function CombosPage() {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
@@ -91,13 +94,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setShowCreateModal(false);
notify.success("Combo created successfully");
notify.success(t("comboCreated"));
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to create combo");
notify.error(err.error?.message || err.error || t("failedCreate"));
}
} catch (error) {
notify.error("Error creating combo");
notify.error(t("errorCreating"));
}
};
@@ -111,26 +114,26 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setEditingCombo(null);
notify.success("Combo updated successfully");
notify.success(t("comboUpdated"));
} else {
const err = await res.json();
notify.error(err.error?.message || err.error || "Failed to update combo");
notify.error(err.error?.message || err.error || t("failedUpdate"));
}
} catch (error) {
notify.error("Error updating combo");
notify.error(t("errorUpdating"));
}
};
const handleDelete = async (id) => {
if (!confirm("Delete this combo?")) return;
if (!confirm(t("deleteConfirm"))) return;
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter((c) => c.id !== id));
notify.success("Combo deleted");
notify.success(t("comboDeleted"));
}
} catch (error) {
notify.error("Error deleting combo");
notify.error(t("errorDeleting"));
}
};
@@ -166,8 +169,8 @@ export default function CombosPage() {
const data = await res.json();
setTestResults(data);
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Test request failed");
setTestResults({ error: t("testFailed") });
notify.error(t("testFailed"));
}
};
@@ -186,7 +189,7 @@ export default function CombosPage() {
setCombos((prev) =>
prev.map((c) => (c.id === combo.id ? { ...c, isActive: !newActive } : c))
);
notify.error("Failed to toggle combo");
notify.error(t("failedToggle"));
}
};
@@ -204,13 +207,11 @@ export default function CombosPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
Create model combos with weighted routing and fallback support
</p>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
{t("createCombo")}
</Button>
</div>
@@ -218,9 +219,9 @@ export default function CombosPage() {
{combos.length === 0 ? (
<EmptyState
icon="🧩"
title="No combos yet"
description="Create model combos with weighted routing and fallback support"
actionLabel="Create Combo"
title={t("noCombosYet")}
description={t("description")}
actionLabel={t("createCombo")}
onAction={() => setShowCreateModal(true)}
/>
) : (
@@ -253,7 +254,7 @@ export default function CombosPage() {
setTestResults(null);
setTestingCombo(null);
}}
title={`Test Results${testingCombo}`}
title={t("testResults", { name: testingCombo })}
>
<TestResultsView results={testResults} />
</Modal>
@@ -313,6 +314,8 @@ function ComboCard({
const strategy = combo.strategy || "priority";
const models = combo.models || [];
const isDisabled = combo.isActive === false;
const t = useTranslations("combos");
const tc = useTranslations("common");
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
@@ -346,7 +349,7 @@ function ComboCard({
{hasProxy && (
<span
className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-primary/15 text-primary flex items-center gap-0.5"
title="Proxy configured"
title={t("proxyConfigured")}
>
<span className="material-symbols-outlined text-[11px]">vpn_lock</span>
proxy
@@ -358,7 +361,7 @@ function ComboCard({
onCopy(combo.name, `combo-${combo.id}`);
}}
className="p-0.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors opacity-0 group-hover:opacity-100"
title="Copy combo name"
title={t("copyComboName")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
@@ -369,7 +372,7 @@ function ComboCard({
{/* Model tags with weights */}
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span>
<span className="text-xs text-text-muted italic">{t("noModels")}</span>
) : (
models.slice(0, 3).map((entry, index) => {
const { model, weight } = normalizeModelEntry(entry);
@@ -385,7 +388,9 @@ function ComboCard({
})
)}
{models.length > 3 && (
<span className="text-[10px] text-text-muted">+{models.length - 3} more</span>
<span className="text-[10px] text-text-muted">
{t("more", { count: models.length - 3 })}
</span>
)}
</div>
@@ -394,9 +399,11 @@ function ComboCard({
<div className="flex items-center gap-3 mt-1">
<span className="text-[10px] text-text-muted">
<span className="text-emerald-500">{metrics.totalSuccesses}</span>/
{metrics.totalRequests} reqs
{metrics.totalRequests} {t("reqs")}
</span>
<span className="text-[10px] text-text-muted">
{metrics.successRate}% {t("success")}
</span>
<span className="text-[10px] text-text-muted">{metrics.successRate}% success</span>
<span className="text-[10px] text-text-muted">~{metrics.avgLatencyMs}ms</span>
{metrics.fallbackRate > 0 && (
<span className="text-[10px] text-amber-500">
@@ -414,14 +421,14 @@ function ComboCard({
size="sm"
checked={!isDisabled}
onChange={onToggle}
title={isDisabled ? "Enable combo" : "Disable combo"}
title={isDisabled ? t("enableCombo") : t("disableCombo")}
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-emerald-500 transition-colors"
title="Test combo"
title={t("testCombo")}
>
<span
className={`material-symbols-outlined text-[16px] ${testing ? "animate-spin" : ""}`}
@@ -432,28 +439,28 @@ function ComboCard({
<button
onClick={onDuplicate}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Duplicate"
title={t("duplicate")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={onProxy}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Proxy configuration"
title={t("proxyConfig")}
>
<span className="material-symbols-outlined text-[16px]">vpn_lock</span>
</button>
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
title={tc("edit")}
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
title={tc("delete")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -531,6 +538,8 @@ function TestResultsView({ results }) {
// Combo Form Modal
// ─────────────────────────────────────────────
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const t = useTranslations("combos");
const tc = useTranslations("common");
const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(() => {
return (combo?.models || []).map((m) => normalizeModelEntry(m));
@@ -575,11 +584,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const validateName = (value) => {
if (!value.trim()) {
setNameError("Name is required");
setNameError(t("nameRequired"));
return false;
}
if (!VALID_NAME_REGEX.test(value)) {
setNameError("Only letters, numbers, -, _, / and . allowed");
setNameError(t("nameInvalid"));
return false;
}
setNameError("");
@@ -631,8 +640,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const parts = modelValue.split("/");
if (parts.length !== 2) return modelValue;
const [providerId, modelId] = parts;
const matchedNode = providerNodes.find((node) => node.id === providerId);
const [providerIdentifier, modelId] = parts;
// Match by node ID or prefix
const matchedNode = providerNodes.find(
(node) => node.id === providerIdentifier || node.prefix === providerIdentifier
);
if (matchedNode) {
return `${matchedNode.name}/${modelId}`;
@@ -723,25 +735,23 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? "Edit Combo" : "Create Combo"}>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
<div className="flex flex-col gap-3">
{/* Name */}
<div>
<Input
label="Combo Name"
label={t("comboName")}
value={name}
onChange={handleNameChange}
placeholder="my-combo"
placeholder={t("comboNamePlaceholder")}
error={nameError}
/>
<p className="text-[10px] text-text-muted mt-0.5">
Letters, numbers, -, _, / and . allowed
</p>
<p className="text-[10px] text-text-muted mt-0.5">{t("nameHint")}</p>
</div>
{/* Strategy Toggle */}
<div>
<label className="text-sm font-medium mb-1.5 block">Routing Strategy</label>
<label className="text-sm font-medium mb-1.5 block">{t("routingStrategy")}</label>
<div className="grid grid-cols-3 gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
{[
{ value: "priority", label: "Priority", icon: "sort" },
@@ -785,13 +795,13 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Models */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-sm font-medium">Models</label>
<label className="text-sm font-medium">{t("models")}</label>
{strategy === "weighted" && models.length > 1 && (
<button
onClick={handleAutoBalance}
className="text-[10px] text-primary hover:text-primary/80 transition-colors"
>
Auto-balance
{t("autoBalance")}
</button>
)}
</div>
@@ -801,7 +811,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-text-muted text-xl mb-1">
layers
</span>
<p className="text-xs text-text-muted">No models added yet</p>
<p className="text-xs text-text-muted">{t("noModelsYet")}</p>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[240px] overflow-y-auto">
@@ -856,7 +866,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move up"
title={t("moveUp")}
>
<span className="material-symbols-outlined text-[12px]">
arrow_upward
@@ -866,7 +876,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onClick={() => handleMoveDown(index)}
disabled={index === models.length - 1}
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move down"
title={t("moveDown")}
>
<span className="material-symbols-outlined text-[12px]">
arrow_downward
@@ -879,7 +889,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<button
onClick={() => handleRemoveModel(index)}
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 transition-all"
title="Remove"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
@@ -897,7 +907,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-text-muted hover:text-primary hover:border-primary/30 transition-colors flex items-center justify-center gap-1"
>
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
{t("addModel")}
</button>
</div>
@@ -909,14 +919,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-[14px]">
{showAdvanced ? "expand_less" : "expand_more"}
</span>
Advanced Settings
{t("advancedSettings")}
</button>
{showAdvanced && (
<div className="flex flex-col gap-2 p-3 bg-black/[0.02] dark:bg-white/[0.02] rounded-lg border border-black/5 dark:border-white/5">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">Max Retries</label>
<label className="text-[10px] text-text-muted mb-0.5 block">
{t("maxRetries")}
</label>
<input
type="number"
min="0"
@@ -934,7 +946,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Retry Delay (ms)
{t("retryDelay")}
</label>
<input
type="number"
@@ -953,7 +965,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
/>
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">Timeout (ms)</label>
<label className="text-[10px] text-text-muted mb-0.5 block">{t("timeout")}</label>
<input
type="number"
min="1000"
@@ -971,7 +983,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-text-muted">Healthcheck</label>
<label className="text-[10px] text-text-muted">{t("healthcheck")}</label>
<input
type="checkbox"
checked={config.healthCheckEnabled !== false}
@@ -984,7 +996,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Concurrency / Model
{t("concurrencyPerModel")}
</label>
<input
type="number"
@@ -1003,7 +1015,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
<div>
<label className="text-[10px] text-text-muted mb-0.5 block">
Queue Timeout (ms)
{t("queueTimeout")}
</label>
<input
type="number"
@@ -1023,16 +1035,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
</div>
)}
<p className="text-[10px] text-text-muted">
Leave empty to use global defaults. These override per-provider settings.
</p>
<p className="text-[10px] text-text-muted">{t("advancedHint")}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
Cancel
{tc("cancel")}
</Button>
<Button
onClick={handleSave}
@@ -1040,7 +1050,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
size="sm"
disabled={!name.trim() || !!nameError || saving}
>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
{saving ? t("saving") : isEdit ? tc("save") : t("createCombo")}
</Button>
</div>
</div>
@@ -1053,7 +1063,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onSelect={handleAddModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
title={t("addModelToCombo")}
selectedModel={null}
/>
</>

View File

@@ -4,16 +4,19 @@ import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import BudgetTab from "../usage/components/BudgetTab";
import PricingTab from "../settings/components/PricingTab";
import { useTranslations } from "next-intl";
export default function CostsPage() {
const [activeTab, setActiveTab] = useState("budget");
const t = useTranslations("costs");
const ts = useTranslations("settings");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "budget", label: "Budget" },
{ value: "pricing", label: "Pricing" },
{ value: "budget", label: t("budget") },
{ value: "pricing", label: ts("pricing") },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -1,22 +1,20 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import Image from "next/image";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
const CLOUD_ACTION_TIMEOUT_MS = 15000;
export default function APIPageClient({ machineId }) {
const [keys, setKeys] = useState([]);
const t = useTranslations("endpoint");
const tc = useTranslations("common");
const [providerConnections, setProviderConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [createdKey, setCreatedKey] = useState(null);
// Endpoints / models state
const [allModels, setAllModels] = useState([]);
@@ -53,16 +51,19 @@ export default function APIPageClient({ machineId }) {
};
// Categorize models by endpoint type
// Filter out parent models (models with parent field set) to avoid showing duplicates
const endpointData = useMemo(() => {
const chat = allModels.filter((m) => !m.type);
const embeddings = allModels.filter((m) => m.type === "embedding");
const images = allModels.filter((m) => m.type === "image");
const rerank = allModels.filter((m) => m.type === "rerank");
const chat = allModels.filter((m) => !m.type && !m.parent);
const embeddings = allModels.filter((m) => m.type === "embedding" && !m.parent);
const images = allModels.filter((m) => m.type === "image" && !m.parent);
const rerank = allModels.filter((m) => m.type === "rerank" && !m.parent);
const audioTranscription = allModels.filter(
(m) => m.type === "audio" && m.subtype === "transcription"
(m) => m.type === "audio" && m.subtype === "transcription" && !m.parent
);
const audioSpeech = allModels.filter((m) => m.type === "audio" && m.subtype === "speech");
const moderation = allModels.filter((m) => m.type === "moderation");
const audioSpeech = allModels.filter(
(m) => m.type === "audio" && m.subtype === "speech" && !m.parent
);
const moderation = allModels.filter((m) => m.type === "moderation" && !m.parent);
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
}, [allModels]);
@@ -108,9 +109,9 @@ export default function APIPageClient({ machineId }) {
return { ok: res.ok, status: res.status, data };
} catch (error) {
if (error?.name === "AbortError") {
return { ok: false, status: 408, data: { error: "Cloud request timeout" } };
return { ok: false, status: 408, data: { error: t("cloudRequestTimeout") } };
}
return { ok: false, status: 500, data: { error: error.message || "Cloud request failed" } };
return { ok: false, status: 500, data: { error: error.message || t("cloudRequestFailed") } };
} finally {
clearTimeout(timeoutId);
}
@@ -130,16 +131,9 @@ export default function APIPageClient({ machineId }) {
const fetchData = async () => {
try {
const [keysRes, providersRes] = await Promise.all([
fetch("/api/keys"),
fetch("/api/providers"),
]);
const providersRes = await fetch("/api/providers");
const [keysData, providersData] = await Promise.all([keysRes.json(), providersRes.json()]);
if (keysRes.ok) {
setKeys(keysData.keys || []);
}
const providersData = await providersRes.json();
if (providersRes.ok) {
setProviderConnections(providersData.connections || []);
@@ -196,13 +190,13 @@ export default function APIPageClient({ machineId }) {
setModalSuccess(false);
if (data.verified) {
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
setCloudStatus({ type: "success", message: t("cloudConnectedVerified") });
} else {
setCloudStatus({
type: "warning",
message: data.verifyError
? `Connected — verification pending: ${data.verifyError}`
: "Connected — verification pending",
? t("connectedVerificationPendingWithError", { error: data.verifyError })
: t("connectedVerificationPending"),
});
}
@@ -214,16 +208,15 @@ export default function APIPageClient({ machineId }) {
await loadCloudSettings();
} else {
// Sync failed — provide a helpful error message
let errorMessage = data.error || "Failed to enable cloud";
let errorMessage = data.error || t("failedEnable");
if (status === 502 || status === 408) {
errorMessage =
"Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).";
errorMessage = t("cloudWorkerUnreachable");
}
setCloudStatus({ type: "error", message: errorMessage });
setShowCloudModal(false);
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message || "Connection failed" });
setCloudStatus({ type: "error", message: error.message || t("connectionFailed") });
setShowCloudModal(false);
} finally {
setCloudSyncing(false);
@@ -246,16 +239,16 @@ export default function APIPageClient({ machineId }) {
if (ok) {
setCloudEnabled(false);
setCloudStatus({ type: "success", message: "Cloud disabled successfully" });
setCloudStatus({ type: "success", message: t("cloudDisabledSuccess") });
setShowDisableModal(false);
dispatchCloudChange();
await loadCloudSettings();
} else {
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
setCloudStatus({ type: "error", message: data.error || t("failedDisable") });
}
} catch (error) {
console.log("Error disabling cloud:", error);
setCloudStatus({ type: "error", message: "Failed to disable cloud" });
setCloudStatus({ type: "error", message: t("failedDisable") });
} finally {
setCloudSyncing(false);
setSyncStep("");
@@ -269,52 +262,17 @@ export default function APIPageClient({ machineId }) {
try {
const { ok, data } = await postCloudAction("sync");
if (ok) {
setCloudStatus({ type: "success", message: "Synced successfully" });
setCloudStatus({ type: "success", message: t("syncedSuccess") });
} else {
setCloudStatus({ type: "error", message: data.error });
setCloudStatus({ type: "error", message: data.error || t("syncFailed") });
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message });
setCloudStatus({ type: "error", message: error.message || t("syncFailed") });
} finally {
setCloudSyncing(false);
}
};
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newKeyName }),
});
const data = await res.json();
if (res.ok) {
setCreatedKey(data.key);
await fetchData();
setNewKeyName("");
setShowAddModal(false);
}
} catch (error) {
console.log("Error creating key:", error);
}
};
const handleDeleteKey = async (id) => {
if (!confirm("Delete this API key?")) return;
try {
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
if (res.ok) {
setKeys(keys.filter((k) => k.id !== id));
}
} catch (error) {
console.log("Error deleting key:", error);
}
};
const [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = `${CLOUD_URL}/v1`;
@@ -337,36 +295,20 @@ export default function APIPageClient({ machineId }) {
// Use new format endpoint (machineId embedded in key)
const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
const cloudBenefits = [
{ icon: "public", title: "Access Anywhere", desc: "No port forwarding needed" },
{ icon: "group", title: "Share Endpoint", desc: "Easy team collaboration" },
{ icon: "schedule", title: "Always Online", desc: "24/7 availability" },
{ icon: "speed", title: "Global Edge", desc: "Fast worldwide access" },
];
const quickStartLinks = [
{ label: "Documentation", href: "/docs" },
{ label: "OpenAI API compatibility", href: "/docs#api-reference" },
{ label: "Cherry/Codex compatibility", href: "/docs#client-compatibility" },
{
label: "Report issue",
href: "https://github.com/diegosouzapw/OmniRoute/issues",
external: true,
},
];
return (
<div className="flex flex-col gap-8">
{/* Endpoint Card */}
<Card className={cloudEnabled ? "" : ""}>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">API Endpoint</h2>
<h2 className="text-lg font-semibold">{t("title")}</h2>
<p className="text-sm text-text-muted">
{cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"}
{cloudEnabled ? t("usingCloudProxy") : t("usingLocalServer")}
</p>
{machineId && (
<p className="text-xs text-text-muted mt-1">Machine ID: {machineId.slice(0, 8)}...</p>
<p className="text-xs text-text-muted mt-1">
{t("machineId", { id: machineId.slice(0, 8) })}
</p>
)}
</div>
<div className="flex items-center gap-2">
@@ -379,7 +321,7 @@ export default function APIPageClient({ machineId }) {
disabled={cloudSyncing}
className="bg-red-500/10! text-red-500! hover:bg-red-500/20! border-red-500/30!"
>
Disable Cloud
{t("disableCloud")}
</Button>
) : (
<Button
@@ -389,7 +331,7 @@ export default function APIPageClient({ machineId }) {
disabled={cloudSyncing}
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600"
>
Enable Cloud
{t("enableCloud")}
</Button>
)}
</div>
@@ -435,109 +377,20 @@ export default function APIPageClient({ machineId }) {
icon={copied === "endpoint_url" ? "check" : "content_copy"}
onClick={() => copy(currentEndpoint, "endpoint_url")}
>
{copied === "endpoint_url" ? "Copied!" : "Copy"}
{copied === "endpoint_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{/* Registered Keys — collapsible section inside API Endpoint card */}
<div className="border border-border rounded-lg overflow-hidden mt-4">
<button
onClick={() => setExpandedEndpoint(expandedEndpoint === "keys" ? null : "keys")}
className="w-full flex items-center gap-3 p-4 hover:bg-surface/50 transition-colors text-left"
>
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 shrink-0">
<span className="material-symbols-outlined text-xl text-amber-500">vpn_key</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">Registered Keys</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
{keys.length} {keys.length === 1 ? "key" : "keys"}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">
Manage API keys used to authenticate requests to this endpoint
</p>
</div>
<span
className={`material-symbols-outlined text-text-muted text-lg transition-transform ${expandedEndpoint === "keys" ? "rotate-180" : ""}`}
>
expand_more
</span>
</button>
{expandedEndpoint === "keys" && (
<div className="border-t border-border px-4 pb-4">
<div className="flex items-center justify-between mt-3 mb-3">
<p className="text-xs text-text-muted">
Each key isolates usage tracking and can be revoked independently.
</p>
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
{keys.length === 0 ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mb-3">
<span className="material-symbols-outlined text-[24px]">vpn_key</span>
</div>
<p className="text-text-main font-medium mb-1 text-sm">No API keys yet</p>
<p className="text-xs text-text-muted mb-3">
Create your first API key to get started
</p>
<Button size="sm" icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
) : (
<div className="flex flex-col">
{keys.map((key) => (
<div
key={key.id}
className="group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{key.name}</p>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs text-text-muted font-mono">{key.key}</code>
<button
onClick={() => copy(key.key, key.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[14px]">
{copied === key.id ? "check" : "content_copy"}
</span>
</button>
</div>
<p className="text-xs text-text-muted mt-1">
Created {new Date(key.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteKey(key.id)}
className="p-2 hover:bg-red-500/10 rounded text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</Card>
{/* Available Endpoints */}
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">Available Endpoints</h2>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<p className="text-sm text-text-muted">
{allModels.length} models across{" "}
{
[
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce((acc, models) => acc + models.length, 0),
endpoints: [
endpointData.chat,
endpointData.embeddings,
endpointData.images,
@@ -545,9 +398,8 @@ export default function APIPageClient({ machineId }) {
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
].filter((a) => a.length > 0).length
}{" "}
endpoints
].filter((a) => a.length > 0).length,
})}
</p>
</div>
</div>
@@ -558,9 +410,9 @@ export default function APIPageClient({ machineId }) {
icon="chat"
iconColor="text-blue-500"
iconBg="bg-blue-500/10"
title="Chat Completions"
title={t("chatCompletions")}
path="/v1/chat/completions"
description="Streaming & non-streaming chat with all providers"
description={t("chatDesc")}
models={endpointData.chat}
expanded={expandedEndpoint === "chat"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")}
@@ -574,9 +426,9 @@ export default function APIPageClient({ machineId }) {
icon="data_array"
iconColor="text-emerald-500"
iconBg="bg-emerald-500/10"
title="Embeddings"
title={t("embeddings")}
path="/v1/embeddings"
description="Text embeddings for search & RAG pipelines"
description={t("embeddingsDesc")}
models={endpointData.embeddings}
expanded={expandedEndpoint === "embeddings"}
onToggle={() =>
@@ -592,9 +444,9 @@ export default function APIPageClient({ machineId }) {
icon="image"
iconColor="text-purple-500"
iconBg="bg-purple-500/10"
title="Image Generation"
title={t("imageGeneration")}
path="/v1/images/generations"
description="Generate images from text prompts"
description={t("imageDesc")}
models={endpointData.images}
expanded={expandedEndpoint === "images"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")}
@@ -608,9 +460,9 @@ export default function APIPageClient({ machineId }) {
icon="sort"
iconColor="text-amber-500"
iconBg="bg-amber-500/10"
title="Rerank"
title={t("rerank")}
path="/v1/rerank"
description="Rerank documents by relevance to a query"
description={t("rerankDesc")}
models={endpointData.rerank}
expanded={expandedEndpoint === "rerank"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")}
@@ -624,9 +476,9 @@ export default function APIPageClient({ machineId }) {
icon="mic"
iconColor="text-rose-500"
iconBg="bg-rose-500/10"
title="Audio Transcription"
title={t("audioTranscription")}
path="/v1/audio/transcriptions"
description="Transcribe audio files to text (Whisper)"
description={t("audioTranscriptionDesc")}
models={endpointData.audioTranscription}
expanded={expandedEndpoint === "audioTranscription"}
onToggle={() =>
@@ -644,9 +496,9 @@ export default function APIPageClient({ machineId }) {
icon="record_voice_over"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title="Text to Speech"
title={t("textToSpeech")}
path="/v1/audio/speech"
description="Convert text to natural-sounding speech"
description={t("textToSpeechDesc")}
models={endpointData.audioSpeech}
expanded={expandedEndpoint === "audioSpeech"}
onToggle={() =>
@@ -662,9 +514,9 @@ export default function APIPageClient({ machineId }) {
icon="shield"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title="Moderations"
title={t("moderations")}
path="/v1/moderations"
description="Content moderation and safety classification"
description={t("moderationsDesc")}
models={endpointData.moderation}
expanded={expandedEndpoint === "moderation"}
onToggle={() =>
@@ -677,97 +529,32 @@ export default function APIPageClient({ machineId }) {
</div>
</Card>
{/* Cloud Proxy Card - Hidden */}
{false && (
<Card className={cloudEnabled ? "bg-primary/5" : ""}>
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={`p-2 rounded-lg ${cloudEnabled ? "bg-primary text-white" : "bg-sidebar text-text-muted"}`}
>
<span className="material-symbols-outlined text-xl">cloud</span>
</div>
<div>
<h2 className="text-lg font-semibold">Cloud Proxy</h2>
<p className="text-xs text-text-muted">
{cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{cloudEnabled ? (
<Button
size="sm"
variant="secondary"
icon="cloud_off"
onClick={() => handleCloudToggle(false)}
disabled={cloudSyncing}
className="bg-red-500/10! text-red-500! hover:bg-red-500/20! border-red-500/30!"
>
Disable
</Button>
) : (
<Button
variant="primary"
icon="cloud_upload"
onClick={() => handleCloudToggle(true)}
disabled={cloudSyncing}
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 px-6"
>
Enable Cloud
</Button>
)}
</div>
</div>
{/* Benefits Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{cloudBenefits.map((benefit) => (
<div
key={benefit.title}
className="flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50"
>
<span className="material-symbols-outlined text-xl text-primary mb-1">
{benefit.icon}
</span>
<p className="text-xs font-semibold">{benefit.title}</p>
<p className="text-xs text-text-muted">{benefit.desc}</p>
</div>
))}
</div>
</div>
</Card>
)}
{/* Cloud Enable Modal */}
<Modal
isOpen={showCloudModal}
title="Enable Cloud Proxy"
title={t("enableCloudTitle")}
onClose={() => setShowCloudModal(false)}
>
<div className="flex flex-col gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-2">
What you will get
{t("whatYouGet")}
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Access your API from anywhere in the world</li>
<li> Share endpoint with your team easily</li>
<li> No need to open ports or configure firewall</li>
<li> Fast global edge network</li>
<li> {t("cloudBenefitAccess")}</li>
<li> {t("cloudBenefitShare")}</li>
<li> {t("cloudBenefitPorts")}</li>
<li> {t("cloudBenefitEdge")}</li>
</ul>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">Note</p>
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
{tc("note")}
</p>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li>
Cloud will keep your auth session for 1 day. If not used, it will be automatically
deleted.
</li>
<li> Cloud is currently unstable with Claude Code OAuth in some cases.</li>
<li> {t("cloudSessionNote")}</li>
<li> {t("cloudUnstableNote")}</li>
</ul>
</div>
@@ -795,9 +582,9 @@ export default function APIPageClient({ machineId }) {
modalSuccess ? "text-green-500" : "text-primary"
}`}
>
{modalSuccess && "Cloud Proxy connected!"}
{!modalSuccess && syncStep === "syncing" && "Connecting to cloud..."}
{!modalSuccess && syncStep === "verifying" && "Verifying connection..."}
{modalSuccess && t("cloudConnected")}
{!modalSuccess && syncStep === "syncing" && t("connectingToCloud")}
{!modalSuccess && syncStep === "verifying" && t("verifyingConnection")}
</p>
</div>
</div>
@@ -810,15 +597,15 @@ export default function APIPageClient({ machineId }) {
<span className="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>
{syncStep === "syncing" ? "Connecting..." : "Verifying..."}
{syncStep === "syncing" ? t("connecting") : t("verifying")}
</span>
) : modalSuccess ? (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined text-sm">check</span>
Connected!
{t("connected")}
</span>
) : (
"Enable Cloud"
t("enableCloud")
)}
</Button>
<Button
@@ -827,77 +614,16 @@ export default function APIPageClient({ machineId }) {
fullWidth
disabled={cloudSyncing || modalSuccess}
>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
</Modal>
{/* Add Key Modal */}
<Modal
isOpen={showAddModal}
title="Create API Key"
onClose={() => {
setShowAddModal(false);
setNewKeyName("");
}}
>
<div className="flex flex-col gap-4">
<Input
label="Key Name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Production Key"
/>
<div className="flex gap-2">
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
Create
</Button>
<Button
onClick={() => {
setShowAddModal(false);
setNewKeyName("");
}}
variant="ghost"
fullWidth
>
Cancel
</Button>
</div>
</div>
</Modal>
{/* Created Key Modal */}
<Modal isOpen={!!createdKey} title="API Key Created" onClose={() => setCreatedKey(null)}>
<div className="flex flex-col gap-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
Save this key now!
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300">
This is the only time you will see this key. Store it securely.
</p>
</div>
<div className="flex gap-2">
<Input value={createdKey || ""} readOnly className="flex-1 font-mono text-sm" />
<Button
variant="secondary"
icon={copied === "created_key" ? "check" : "content_copy"}
onClick={() => copy(createdKey, "created_key")}
>
{copied === "created_key" ? "Copied!" : "Copy"}
</Button>
</div>
<Button onClick={() => setCreatedKey(null)} fullWidth>
Done
</Button>
</div>
</Modal>
{/* Disable Cloud Modal */}
<Modal
isOpen={showDisableModal}
title="Disable Cloud Proxy"
title={t("disableCloudTitle")}
onClose={() => !cloudSyncing && setShowDisableModal(false)}
>
<div className="flex flex-col gap-4">
@@ -907,10 +633,10 @@ export default function APIPageClient({ machineId }) {
warning
</span>
<div>
<p className="text-sm text-red-800 dark:text-red-200 font-medium mb-1">Warning</p>
<p className="text-sm text-red-700 dark:text-red-300">
All auth sessions will be deleted from cloud.
<p className="text-sm text-red-800 dark:text-red-200 font-medium mb-1">
{tc("warning")}
</p>
<p className="text-sm text-red-700 dark:text-red-300">{t("disableWarning")}</p>
</div>
</div>
</div>
@@ -923,14 +649,14 @@ export default function APIPageClient({ machineId }) {
</span>
<div className="flex-1">
<p className="text-sm font-medium text-primary">
{syncStep === "syncing" && "Syncing latest data..."}
{syncStep === "disabling" && "Disabling cloud..."}
{syncStep === "syncing" && t("syncingData")}
{syncStep === "disabling" && t("disablingCloud")}
</p>
</div>
</div>
)}
<p className="text-sm text-text-muted">Are you sure you want to disable cloud proxy?</p>
<p className="text-sm text-text-muted">{t("disableConfirm")}</p>
<div className="flex gap-2">
<Button
@@ -944,10 +670,10 @@ export default function APIPageClient({ machineId }) {
<span className="material-symbols-outlined animate-spin text-sm">
progress_activity
</span>
{syncStep === "syncing" ? "Syncing..." : "Disabling..."}
{syncStep === "syncing" ? t("syncing") : t("disabling")}
</span>
) : (
"Disable Cloud"
t("disableCloud")
)}
</Button>
<Button
@@ -956,7 +682,7 @@ export default function APIPageClient({ machineId }) {
fullWidth
disabled={cloudSyncing}
>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
@@ -979,83 +705,18 @@ APIPageClient.propTypes = {
machineId: PropTypes.string.isRequired,
};
function ProviderOverviewCard({ item, onClick }) {
const [imgError, setImgError] = useState(false);
const statusVariant =
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
return (
<div
className="border border-border rounded-lg p-3 hover:bg-surface/40 transition-colors cursor-pointer"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
>
<div className="flex items-center gap-2.5">
<div
className="size-8 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${item.provider.color || "#888"}15` }}
>
{imgError ? (
<span
className="text-[10px] font-bold"
style={{ color: item.provider.color || "#888" }}
>
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={`/providers/${item.provider.id}.png`}
alt={item.provider.name}
width={26}
height={26}
className="object-contain rounded-lg"
sizes="26px"
onError={() => setImgError(true)}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{item.provider.name}</p>
<p className={`text-xs ${statusVariant}`}>
{item.total === 0
? "Not configured"
: `${item.connected} active · ${item.errors} error`}
</p>
</div>
<span className="text-xs text-text-muted">#{item.total}</span>
</div>
</div>
);
}
ProviderOverviewCard.propTypes = {
item: PropTypes.shape({
id: PropTypes.string.isRequired,
provider: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string,
textIcon: PropTypes.string,
}).isRequired,
total: PropTypes.number.isRequired,
connected: PropTypes.number.isRequired,
errors: PropTypes.number.isRequired,
}).isRequired,
onClick: PropTypes.func,
};
// -- Sub-component: Provider Models Modal ------------------------------------------
function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
const t = useTranslations("endpoint");
const tc = useTranslations("common");
// Get provider alias for matching models
// Filter out parent models (models with parent field set) to avoid showing duplicates
const providerAlias = provider.provider.alias || provider.id;
const providerModels = useMemo(() => {
return models.filter((m) => m.owned_by === providerAlias || m.owned_by === provider.id);
return models.filter(
(m) => !m.parent && (m.owned_by === providerAlias || m.owned_by === provider.id)
);
}, [models, providerAlias, provider.id]);
const chatModels = providerModels.filter((m) => !m.type);
@@ -1081,13 +742,13 @@ function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
<code className="text-sm font-mono flex-1 truncate">{m.id}</code>
{m.custom && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">
custom
{t("custom")}
</span>
)}
<button
onClick={() => copy(m.id, copyKey)}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary opacity-0 group-hover:opacity-100 transition-opacity"
title="Copy model ID"
title={tc("copy")}
>
<span className="material-symbols-outlined text-sm">
{copied === copyKey ? "check" : "content_copy"}
@@ -1102,17 +763,19 @@ function ProviderModelsModal({ provider, models, copy, copied, onClose }) {
};
return (
<Modal isOpen onClose={onClose} title={`${provider.provider.name} — Models`}>
<Modal
isOpen
onClose={onClose}
title={t("providerModelsTitle", { provider: provider.provider.name })}
>
<div className="max-h-[60vh] overflow-y-auto">
{providerModels.length === 0 ? (
<p className="text-sm text-text-muted py-4 text-center">
No models available for this provider.
</p>
<p className="text-sm text-text-muted py-4 text-center">{t("noModelsForProvider")}</p>
) : (
<>
{renderModelGroup("Chat", "chat", chatModels)}
{renderModelGroup("Embedding", "data_array", embeddingModels)}
{renderModelGroup("Image", "image", imageModels)}
{renderModelGroup(t("chat"), "chat", chatModels)}
{renderModelGroup(t("embedding"), "data_array", embeddingModels)}
{renderModelGroup(t("image"), "image", imageModels)}
</>
)}
</div>
@@ -1144,6 +807,7 @@ function EndpointSection({
copied,
baseUrl,
}) {
const t = useTranslations("endpoint");
const grouped = useMemo(() => {
const map = {};
for (const m of models) {
@@ -1151,7 +815,9 @@ function EndpointSection({
if (!map[owner]) map[owner] = [];
map[owner].push(m);
}
return Object.entries(map).sort((a: any, b: any) => (b[1] as any).length - (a[1] as any).length);
return Object.entries(map).sort(
(a: any, b: any) => (b[1] as any).length - (a[1] as any).length
);
}, [models]);
const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id);
@@ -1173,7 +839,7 @@ function EndpointSection({
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">{title}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface text-text-muted font-medium">
{models.length} {models.length === 1 ? "model" : "models"}
{t("modelsCount", { count: models.length })}
</span>
</div>
<p className="text-xs text-text-muted mt-0.5">{description}</p>
@@ -1216,7 +882,9 @@ function EndpointSection({
<span className="text-xs font-semibold text-text-main">
{providerName(providerId)}
</span>
<span className="text-xs text-text-muted">({(providerModels as any).length})</span>
<span className="text-xs text-text-muted">
({(providerModels as any).length})
</span>
</div>
<div className="ml-5 flex flex-wrap gap-1.5">
{(providerModels as any).map((m) => (

View File

@@ -15,6 +15,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -31,13 +32,16 @@ function formatBytes(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const CB_COLORS = {
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", label: "Healthy" },
OPEN: { bg: "bg-red-500/10", text: "text-red-500", label: "Open" },
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", label: "Half-Open" },
const CB_STYLES = {
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", labelKey: "healthy" },
OPEN: { bg: "bg-red-500/10", text: "text-red-500", labelKey: "down" },
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", labelKey: "recovering" },
};
export default function HealthPage() {
const t = useTranslations("health");
const tc = useTranslations("common");
const tp = useTranslations("providers");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [lastRefresh, setLastRefresh] = useState(null);
@@ -84,12 +88,7 @@ export default function HealthPage() {
}, [fetchHealth, fetchExtras]);
const handleResetHealth = async () => {
if (
!confirm(
"Reset all circuit breakers to healthy state? This will clear all failure counts and restore all providers to operational status."
)
)
return;
if (!confirm(t("resetConfirm"))) return;
setResetting(true);
try {
const res = await fetch("/api/monitoring/health", { method: "DELETE" });
@@ -104,14 +103,15 @@ export default function HealthPage() {
}
};
const fmtMs = (ms) => (ms != null ? `${Math.round(ms)}ms` : "—");
const fmtMs = (ms) =>
ms != null ? t("millisecondsShort", { value: Math.round(ms) }) : t("notAvailable");
if (!data && !error) {
return (
<div className="p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-text-muted mt-4">Loading health data...</p>
<p className="text-text-muted mt-4">{t("loadingHealth")}</p>
</div>
</div>
);
@@ -122,12 +122,12 @@ export default function HealthPage() {
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2">error</span>
<p className="text-red-400">Failed to load health data: {error}</p>
<p className="text-red-400">{t("failedToLoad", { error })}</p>
<button
onClick={fetchHealth}
className="mt-4 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors"
>
Retry
{t("retry")}
</button>
</div>
</div>
@@ -143,15 +143,13 @@ export default function HealthPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-main">System Health</h1>
<p className="text-sm text-text-muted mt-1">
Real-time monitoring of your OmniRoute instance
</p>
<h1 className="text-2xl font-bold text-text-main">{t("title")}</h1>
<p className="text-sm text-text-muted mt-1">{t("description")}</p>
</div>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-xs text-text-muted">
Updated {lastRefresh.toLocaleTimeString()}
{t("updatedAt", { time: lastRefresh.toLocaleTimeString() })}
</span>
)}
<button
@@ -160,7 +158,7 @@ export default function HealthPage() {
fetchExtras();
}}
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
title="Refresh"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
@@ -185,7 +183,7 @@ export default function HealthPage() {
{data.status === "healthy" ? "check_circle" : "error"}
</span>
<span className={data.status === "healthy" ? "text-green-400" : "text-red-400"}>
{data.status === "healthy" ? "All systems operational" : "System issues detected"}
{data.status === "healthy" ? t("allOperational") : t("issuesDetected")}
</span>
</div>
@@ -196,7 +194,7 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[18px]">timer</span>
</div>
<span className="text-sm text-text-muted">Uptime</span>
<span className="text-sm text-text-muted">{t("uptime")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{formatUptime(system.uptime)}</p>
</Card>
@@ -206,10 +204,12 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500">
<span className="material-symbols-outlined text-[18px]">info</span>
</div>
<span className="text-sm text-text-muted">Version</span>
<span className="text-sm text-text-muted">{t("version")}</span>
</div>
<p className="text-xl font-semibold text-text-main">v{system.version}</p>
<p className="text-xs text-text-muted mt-1">Node {system.nodeVersion}</p>
<p className="text-xs text-text-muted mt-1">
{t("nodeVersion", { version: system.nodeVersion })}
</p>
</Card>
<Card className="p-4">
@@ -217,13 +217,13 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[18px]">memory</span>
</div>
<span className="text-sm text-text-muted">Memory (RSS)</span>
<span className="text-sm text-text-muted">{t("memoryRss")}</span>
</div>
<p className="text-xl font-semibold text-text-main">
{formatBytes(system.memoryUsage?.rss || 0)}
</p>
<p className="text-xs text-text-muted mt-1">
Heap: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
{t("heap")}: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
{formatBytes(system.memoryUsage?.heapTotal || 0)}
</p>
</Card>
@@ -233,11 +233,13 @@ export default function HealthPage() {
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[18px]">dns</span>
</div>
<span className="text-sm text-text-muted">Providers</span>
<span className="text-sm text-text-muted">{t("providers")}</span>
</div>
<p className="text-xl font-semibold text-text-main">{cbEntries.length}</p>
<p className="text-xs text-text-muted mt-1">
{cbEntries.filter(([, v]: [string, any]) => v.state === "CLOSED").length} healthy
{t("healthyCount", {
count: cbEntries.filter(([, v]: [string, any]) => v.state === "CLOSED").length,
})}
</p>
</Card>
</div>
@@ -248,29 +250,29 @@ export default function HealthPage() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">speed</span>
Latency
{t("latency")}
</h3>
{telemetry ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">p50</span>
<span className="text-text-muted">{t("latencyP50")}</span>
<span className="font-mono">{fmtMs(telemetry.p50)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p95</span>
<span className="text-text-muted">{t("latencyP95")}</span>
<span className="font-mono">{fmtMs(telemetry.p95)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">p99</span>
<span className="text-text-muted">{t("latencyP99")}</span>
<span className="font-mono">{fmtMs(telemetry.p99)}</span>
</div>
<div className="flex justify-between border-t border-border pt-2 mt-2">
<span className="text-text-muted">Total requests</span>
<span className="text-text-muted">{t("totalRequests")}</span>
<span className="font-mono">{telemetry.totalRequests ?? 0}</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
@@ -278,29 +280,29 @@ export default function HealthPage() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">cached</span>
Prompt Cache
{t("promptCache")}
</h3>
{cache ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">Entries</span>
<span className="text-text-muted">{t("entries")}</span>
<span className="font-mono">
{cache.size}/{cache.maxSize}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hit Rate</span>
<span className="text-text-muted">{t("hitRate")}</span>
<span className="font-mono">{cache.hitRate?.toFixed(1) ?? 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Hits / Misses</span>
<span className="text-text-muted">{t("hitsMisses")}</span>
<span className="font-mono">
{cache.hits ?? 0} / {cache.misses ?? 0}
</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
@@ -308,24 +310,28 @@ export default function HealthPage() {
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">database</span>
Signature Cache
{t("signatureCache")}
</h3>
{signatureCache ? (
<div className="grid grid-cols-2 gap-2">
{[
{ label: "Defaults", value: signatureCache.defaultCount, color: "text-text-muted" },
{
label: "Tool",
label: t("signatureDefaults"),
value: signatureCache.defaultCount,
color: "text-text-muted",
},
{
label: t("signatureTool"),
value: `${signatureCache.tool.entries}/${signatureCache.tool.patterns}`,
color: "text-blue-400",
},
{
label: "Family",
label: t("signatureFamily"),
value: `${signatureCache.family.entries}/${signatureCache.family.patterns}`,
color: "text-purple-400",
},
{
label: "Session",
label: t("signatureSession"),
value: `${signatureCache.session.entries}/${signatureCache.session.patterns}`,
color: "text-cyan-400",
},
@@ -340,19 +346,19 @@ export default function HealthPage() {
))}
</div>
) : (
<p className="text-sm text-text-muted">No data yet</p>
<p className="text-sm text-text-muted">{t("noDataYet")}</p>
)}
</Card>
</div>
{/* Provider Health */}
<Card className="p-5" role="region" aria-label="Provider health status">
<Card className="p-5" role="region" aria-label={t("providerHealthStatusAria")}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">
health_and_safety
</span>
Provider Health
{t("providerHealth")}
</h2>
<div className="flex items-center gap-3">
{cbEntries.some(([, cb]: [string, any]) => cb.state !== "CLOSED") && (
@@ -364,19 +370,19 @@ export default function HealthPage() {
? "bg-surface/50 text-text-muted cursor-wait"
: "bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300 border border-red-500/20"
}`}
title="Reset all circuit breakers to healthy state"
title={t("resetAllTitle")}
>
{resetting ? (
<>
<span className="material-symbols-outlined text-[14px] animate-spin">
progress_activity
</span>
Resetting...
{t("resetting")}
</>
) : (
<>
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
Reset All
{t("resetAll")}
</>
)}
</button>
@@ -384,22 +390,20 @@ export default function HealthPage() {
{cbEntries.length > 0 && (
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> Healthy
<span className="size-2 rounded-full bg-green-500" /> {t("healthy")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-amber-500" /> Recovering
<span className="size-2 rounded-full bg-amber-500" /> {t("recovering")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> Down
<span className="size-2 rounded-full bg-red-500" /> {t("down")}
</span>
</div>
)}
</div>
</div>
{cbEntries.length === 0 ? (
<p className="text-sm text-text-muted text-center py-4">
No circuit breaker data available. Make some requests first.
</p>
<p className="text-sm text-text-muted text-center py-4">{t("noCBData")}</p>
) : (
(() => {
const unhealthy = cbEntries.filter(([, cb]: [string, any]) => cb.state !== "CLOSED");
@@ -410,10 +414,10 @@ export default function HealthPage() {
{unhealthy.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-red-400 uppercase tracking-wide">
Issues Detected
{t("issuesLabel")}
</p>
{unhealthy.map(([provider, cb]: [string, any]) => {
const style = CB_COLORS[cb.state] || CB_COLORS.OPEN;
const style = CB_STYLES[cb.state] || CB_STYLES.OPEN;
const providerInfo = AI_PROVIDERS[provider];
const displayName = providerInfo?.name || provider;
return (
@@ -438,14 +442,17 @@ export default function HealthPage() {
<span
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${style.bg} ${style.text}`}
>
{style.label}
{t(style.labelKey)}
</span>
</div>
<div className="text-xs text-text-muted mt-0.5">
{cb.failures} failure{cb.failures !== 1 ? "s" : ""}
{cb.failures === 1
? t("failures", { count: cb.failures })
: t("failuresPlural", { count: cb.failures })}
{cb.lastFailure && (
<span className="ml-2">
· Last: {new Date(cb.lastFailure).toLocaleTimeString()}
· {t("lastFailure")}:{" "}
{new Date(cb.lastFailure).toLocaleTimeString()}
</span>
)}
</div>
@@ -461,7 +468,7 @@ export default function HealthPage() {
<div>
{unhealthy.length > 0 && (
<p className="text-xs font-medium text-green-400 uppercase tracking-wide mb-2">
Operational
{t("operational")}
</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
@@ -509,13 +516,13 @@ export default function HealthPage() {
if (providerId.startsWith("openai-compatible-")) {
const customName = providerId.replace("openai-compatible-", "");
displayName = `OpenAI Compatible`;
displayName = tp("openaiCompatibleName");
providerInfo = { color: "#10A37F", textIcon: "OC" };
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
else if (customName) displayName += ` (${customName})`;
} else if (providerId.startsWith("anthropic-compatible-")) {
const customName = providerId.replace("anthropic-compatible-", "");
displayName = `Anthropic Compatible`;
displayName = tp("anthropicCompatibleName");
providerInfo = { color: "#D97757", textIcon: "AC" };
if (customName.length > 12) displayName += ` (${customName.slice(0, 8)}…)`;
else if (customName) displayName += ` (${customName})`;
@@ -548,10 +555,12 @@ export default function HealthPage() {
<span className="material-symbols-outlined text-[20px] text-amber-500">
speed
</span>
Rate Limit Status
{t("rateLimitStatus")}
</h2>
<span className="text-xs text-text-muted">
{entries.length} active limiter{entries.length !== 1 ? "s" : ""}
{entries.length === 1
? t("activeLimiters", { count: entries.length })
: t("activeLimitersPlural", { count: entries.length })}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@@ -605,19 +614,19 @@ export default function HealthPage() {
: "bg-green-500/10 text-green-400"
}`}
>
{isQueued ? "Queued" : isActive ? "Active" : "OK"}
{isQueued ? t("queued") : isActive ? tc("active") : t("ok")}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{status.queued || 0} queued
{t("queuedCount", { count: status.queued || 0 })}
</span>
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">
play_arrow
</span>
{status.running || 0} running
{t("runningCount", { count: status.running || 0 })}
</span>
</div>
</div>
@@ -634,7 +643,7 @@ export default function HealthPage() {
<Card className="p-5">
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-red-500">lock</span>
Active Lockouts
{t("activeLockouts")}
</h2>
<div className="space-y-2">
{lockoutEntries.map(([key, lockout]: [string, any]) => (
@@ -650,7 +659,7 @@ export default function HealthPage() {
</div>
{lockout.until && (
<span className="text-xs text-red-400">
Until {new Date(lockout.until).toLocaleTimeString()}
{t("until", { time: new Date(lockout.until).toLocaleTimeString() })}
</span>
)}
</div>

View File

@@ -6,6 +6,7 @@
*/
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface AuditEntry {
id: number;
@@ -27,6 +28,7 @@ export default function AuditLogTab() {
const [actorFilter, setActorFilter] = useState("");
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const t = useTranslations("logs");
const fetchEntries = useCallback(async () => {
setLoading(true);
@@ -45,11 +47,11 @@ export default function AuditLogTab() {
setHasMore(data.length > PAGE_SIZE);
setEntries(data.slice(0, PAGE_SIZE));
} catch (err: any) {
setError(err.message || "Failed to fetch audit log");
setError(err.message || t("failedFetchAuditLog"));
} finally {
setLoading(false);
}
}, [actionFilter, actorFilter, offset]);
}, [actionFilter, actorFilter, offset, t]);
useEffect(() => {
fetchEntries();
@@ -85,18 +87,16 @@ export default function AuditLogTab() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">Audit Log</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
Administrative actions and security events
</p>
<h2 className="text-xl font-bold text-[var(--color-text-main)]">{t("auditLog")}</h2>
<p className="text-sm text-[var(--color-text-muted)] mt-1">{t("auditLogDesc")}</p>
</div>
<button
onClick={fetchEntries}
disabled={loading}
aria-label="Refresh audit log"
aria-label={t("refreshAuditLogAria")}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
{loading ? t("loading") : t("refresh")}
</button>
</div>
@@ -104,31 +104,31 @@ export default function AuditLogTab() {
<div
className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]"
role="search"
aria-label="Filter audit log entries"
aria-label={t("filterEntriesAria")}
>
<input
type="text"
placeholder="Filter by action..."
placeholder={t("filterByAction")}
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by action type"
aria-label={t("filterByActionTypeAria")}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<input
type="text"
placeholder="Filter by actor..."
placeholder={t("filterByActor")}
value={actorFilter}
onChange={(e) => setActorFilter(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
aria-label="Filter by actor"
aria-label={t("filterByActorAria")}
className="flex-1 min-w-[180px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<button
onClick={handleSearch}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--color-accent)] text-white hover:bg-[var(--color-accent-hover)] transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Search
{t("search")}
</button>
</div>
@@ -144,32 +144,34 @@ export default function AuditLogTab() {
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Audit log entries">
<table className="w-full text-sm" role="table" aria-label={t("tableAria")}>
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Timestamp
{t("timestamp")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Action
{t("action")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Actor
{t("actor")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Target
{t("target")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
Details
{t("details")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">
{t("ipAddress")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)]">IP</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && !loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-[var(--color-text-muted)]">
No audit log entries found
{t("noEntries")}
</td>
</tr>
) : (
@@ -190,13 +192,13 @@ export default function AuditLogTab() {
</td>
<td className="px-4 py-3 text-[var(--color-text-main)]">{entry.actor}</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[200px] truncate">
{entry.target || "—"}
{entry.target || t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] max-w-[300px] truncate font-mono text-xs">
{entry.details ? JSON.stringify(entry.details) : "—"}
{entry.details ? JSON.stringify(entry.details) : t("notAvailable")}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] font-mono text-xs whitespace-nowrap">
{entry.ip_address || "—"}
{entry.ip_address || t("notAvailable")}
</td>
</tr>
))
@@ -208,7 +210,7 @@ export default function AuditLogTab() {
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-xs text-[var(--color-text-muted)]">
Showing {entries.length} entries (offset {offset})
{t("showing", { count: entries.length, offset })}
</p>
<div className="flex gap-2">
<button
@@ -216,14 +218,14 @@ export default function AuditLogTab() {
disabled={offset === 0}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Previous
{t("previous")}
</button>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-30 transition-colors"
>
Next
{t("next")}
</button>
</div>
</div>

View File

@@ -4,18 +4,20 @@ import { useState } from "react";
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import AuditLogTab from "./AuditLogTab";
import { useTranslations } from "next-intl";
export default function LogsPage() {
const [activeTab, setActiveTab] = useState("request-logs");
const t = useTranslations("logs");
return (
<div className="flex flex-col gap-6">
<SegmentedControl
options={[
{ value: "request-logs", label: "Request Logs" },
{ value: "proxy-logs", label: "Proxy Logs" },
{ value: "audit-logs", label: "Audit Logs" },
{ value: "console", label: "Console" },
{ value: "request-logs", label: t("requestLogs") },
{ value: "proxy-logs", label: t("proxyLogs") },
{ value: "audit-logs", label: t("auditLog") },
{ value: "console", label: t("console") },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -2,14 +2,10 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
const STEPS = [
{ id: "welcome", title: "Welcome", icon: "waving_hand" },
{ id: "security", title: "Security", icon: "lock" },
{ id: "provider", title: "Provider", icon: "dns" },
{ id: "test", title: "Test", icon: "play_circle" },
{ id: "done", title: "Ready!", icon: "check_circle" },
];
const STEP_IDS = ["welcome", "security", "provider", "test", "done"];
const STEP_ICONS = ["waving_hand", "lock", "dns", "play_circle", "check_circle"];
const COMMON_PROVIDERS = [
{ id: "openai", name: "OpenAI", color: "#10A37F" },
@@ -22,6 +18,8 @@ const COMMON_PROVIDERS = [
export default function OnboardingWizard() {
const router = useRouter();
const t = useTranslations("onboarding");
const tc = useTranslations("common");
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1");
@@ -70,6 +68,12 @@ export default function OnboardingWizard() {
checkSetup();
}, [router]);
const STEPS = STEP_IDS.map((id, i) => ({
id,
title: t(id === "done" ? "ready" : id),
icon: STEP_ICONS[i],
}));
const currentStep = STEPS[step];
const isLastStep = step === STEPS.length - 1;
@@ -98,12 +102,12 @@ export default function OnboardingWizard() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to set password. Try again.");
setErrorMessage(data.error || t("failedSetPassword"));
return;
}
handleNext();
} catch {
setErrorMessage("Connection error. Please try again.");
setErrorMessage(t("connectionError"));
}
};
@@ -133,18 +137,18 @@ export default function OnboardingWizard() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setErrorMessage(data.error || "Failed to add provider. Try again.");
setErrorMessage(data.error || t("failedAddProvider"));
return;
}
handleNext();
} catch {
setErrorMessage("Connection error. Please try again.");
setErrorMessage(t("connectionError"));
}
};
const handleTestProvider = async () => {
setTestStatus("testing");
setTestMessage("Testing connection...");
setTestMessage(t("testingConnection"));
try {
const res = await fetch("/api/providers");
if (!res.ok) throw new Error("Failed to fetch");
@@ -152,21 +156,21 @@ export default function OnboardingWizard() {
const conn = data.connections?.[0];
if (!conn) {
setTestStatus("error");
setTestMessage("No provider found. You can add one from the dashboard later.");
setTestMessage(t("noProviderFound"));
return;
}
const testRes = await fetch(`/api/providers/${conn.id}/test`, { method: "POST" });
if (testRes.ok) {
setTestStatus("success");
setTestMessage("Connection successful! Your provider is ready.");
setTestMessage(t("connectionSuccessful"));
} else {
const err = await testRes.json().catch(() => ({}));
setTestStatus("error");
setTestMessage(err.error || "Test failed, but you can configure this later.");
setTestMessage(err.error || t("testFailed"));
}
} catch {
setTestStatus("error");
setTestMessage("Could not test right now. You can test from the dashboard.");
setTestMessage(t("couldNotTest"));
}
};
@@ -186,7 +190,7 @@ export default function OnboardingWizard() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="animate-pulse text-text-muted">Loading...</div>
<div className="animate-pulse text-text-muted">{tc("loading")}</div>
</div>
);
}
@@ -243,16 +247,12 @@ export default function OnboardingWizard() {
{/* Welcome */}
{currentStep.id === "welcome" && (
<div className="text-center space-y-4">
<p className="text-text-muted">
<strong className="text-text-main">OmniRoute</strong> is your local AI API proxy.
It routes requests to multiple AI providers with load balancing, failover, and
usage tracking.
</p>
<p className="text-text-muted">{t("welcomeDesc")}</p>
<div className="grid grid-cols-3 gap-3 mt-6">
{[
{ icon: "swap_horiz", label: "Multi-Provider" },
{ icon: "monitoring", label: "Usage Tracking" },
{ icon: "shield", label: "API Key Mgmt" },
{ icon: "swap_horiz", label: t("multiProvider") },
{ icon: "monitoring", label: t("usageTracking") },
{ icon: "shield", label: t("apiKeyMgmt") },
].map((f) => (
<div
key={f.icon}
@@ -271,9 +271,7 @@ export default function OnboardingWizard() {
{/* Security */}
{currentStep.id === "security" && (
<div className="space-y-4">
<p className="text-sm text-text-muted text-center">
Set a password to protect your dashboard, or skip for now.
</p>
<p className="text-sm text-text-muted text-center">{t("securityDesc")}</p>
<label className="flex items-center gap-2 cursor-pointer text-sm text-text-muted">
<input
type="checkbox"
@@ -281,26 +279,26 @@ export default function OnboardingWizard() {
onChange={(e) => setSkipSecurity(e.target.checked)}
className="accent-primary"
/>
Skip password setup
{t("skipPassword")}
</label>
{!skipSecurity && (
<div className="space-y-3">
<input
type="password"
placeholder="Enter password"
placeholder={t("enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<input
type="password"
placeholder="Confirm password"
placeholder={t("confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
{password && confirmPassword && password !== confirmPassword && (
<p className="text-xs text-red-400">Passwords do not match</p>
<p className="text-xs text-red-400">{t("passwordsMismatch")}</p>
)}
</div>
)}
@@ -310,9 +308,7 @@ export default function OnboardingWizard() {
{/* Provider */}
{currentStep.id === "provider" && (
<div className="space-y-4">
<p className="text-sm text-text-muted text-center">
Connect your first AI provider. You can add more later.
</p>
<p className="text-sm text-text-muted text-center">{t("providerDesc")}</p>
<div className="grid grid-cols-3 gap-2">
{COMMON_PROVIDERS.map((p) => (
<button
@@ -335,14 +331,14 @@ export default function OnboardingWizard() {
<div className="space-y-3 mt-4">
<input
type="password"
placeholder="API Key (required)"
placeholder={t("apiKeyRequired")}
value={providerKey}
onChange={(e) => setProviderKey(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
<input
type="text"
placeholder="Custom URL (optional)"
placeholder={t("customUrlOptional")}
value={providerUrl}
onChange={(e) => setProviderUrl(e.target.value)}
className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40"
@@ -355,15 +351,13 @@ export default function OnboardingWizard() {
{/* Test */}
{currentStep.id === "test" && (
<div className="text-center space-y-4">
<p className="text-sm text-text-muted">
Let&apos;s verify your provider connection works.
</p>
<p className="text-sm text-text-muted">{t("testDesc")}</p>
{testStatus === "idle" && (
<button
onClick={handleTestProvider}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
Run Connection Test
{t("runTest")}
</button>
)}
{testStatus === "testing" && (
@@ -390,7 +384,7 @@ export default function OnboardingWizard() {
onClick={handleTestProvider}
className="text-xs text-text-muted underline cursor-pointer"
>
Retry
{t("retry")}
</button>
</div>
)}
@@ -400,12 +394,9 @@ export default function OnboardingWizard() {
{/* Done */}
{currentStep.id === "done" && (
<div className="text-center space-y-4">
<p className="text-text-muted">
You&apos;re all set! Your OmniRoute instance is configured and ready to proxy AI
requests.
</p>
<p className="text-text-muted">{t("doneDesc")}</p>
<div className="bg-white/[0.03] rounded-xl p-4 border border-white/[0.06] text-left">
<p className="text-xs text-text-muted mb-2 font-medium">Your endpoint:</p>
<p className="text-xs text-text-muted mb-2 font-medium">{t("yourEndpoint")}</p>
<code className="text-sm text-primary">{apiEndpoint}</code>
</div>
</div>
@@ -420,7 +411,7 @@ export default function OnboardingWizard() {
onClick={handleBack}
className="px-4 py-2 text-sm text-text-muted hover:text-text-main transition-colors cursor-pointer"
>
Back
{tc("back")}
</button>
)}
</div>
@@ -430,7 +421,7 @@ export default function OnboardingWizard() {
onClick={handleNext}
className="px-4 py-2 text-sm text-text-muted hover:text-text-main transition-colors cursor-pointer"
>
Skip
{t("skip")}
</button>
)}
{currentStep.id === "welcome" && (
@@ -438,7 +429,7 @@ export default function OnboardingWizard() {
onClick={handleNext}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
Get Started
{t("getStarted")}
</button>
)}
{currentStep.id === "security" && (
@@ -447,7 +438,7 @@ export default function OnboardingWizard() {
disabled={!skipSecurity && (!password || password !== confirmPassword)}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{skipSecurity ? "Skip & Continue" : "Set Password"}
{skipSecurity ? t("skipAndContinue") : t("setPassword")}
</button>
)}
{currentStep.id === "provider" && (
@@ -456,7 +447,7 @@ export default function OnboardingWizard() {
disabled={!selectedProvider || !providerKey}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Add Provider
{t("addProvider")}
</button>
)}
{currentStep.id === "test" && (
@@ -464,7 +455,7 @@ export default function OnboardingWizard() {
onClick={handleNext}
className="px-6 py-2.5 bg-primary rounded-lg text-white font-medium text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
{testStatus === "success" ? "Continue" : "Skip"}
{testStatus === "success" ? t("continue") : t("skip")}
</button>
)}
{isLastStep && (
@@ -472,7 +463,7 @@ export default function OnboardingWizard() {
onClick={handleFinish}
className="px-6 py-2.5 bg-green-500 rounded-lg text-white font-medium text-sm hover:bg-green-500/90 transition-colors cursor-pointer"
>
Go to Dashboard
{t("goToDashboard")}
</button>
)}
</div>
@@ -486,7 +477,7 @@ export default function OnboardingWizard() {
onClick={handleFinish}
className="text-xs text-text-muted/60 hover:text-text-muted transition-colors cursor-pointer"
>
Skip wizard entirely
{t("skipWizard")}
</button>
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -9,22 +9,26 @@
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityBadge() {
const [data, setData] = useState(null);
const t = useTranslations("providers");
const tc = useTranslations("common");
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: t("available") },
cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
};
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [clearing, setClearing] = useState(null);
const ref = useRef(null);
const [clearing, setClearing] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
@@ -49,8 +53,8 @@ export default function ModelAvailabilityBadge() {
// Close popover on outside click
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setExpanded(false);
}
};
@@ -58,7 +62,7 @@ export default function ModelAvailabilityBadge() {
return () => document.removeEventListener("mousedown", handleClick);
}, [expanded]);
const handleClearCooldown = async (provider, model) => {
const handleClearCooldown = async (provider: string, model: string) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
@@ -67,13 +71,13 @@ export default function ModelAvailabilityBadge() {
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
notify.success(t("cooldownCleared", { model }));
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
}
} catch {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
} finally {
setClearing(null);
}
@@ -83,12 +87,12 @@ export default function ModelAvailabilityBadge() {
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
const isHealthy = unavailableCount === 0;
// Group unhealthy models by provider
const byProvider = {};
models.forEach((m) => {
const byProvider: Record<string, any[]> = {};
models.forEach((m: any) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
@@ -105,12 +109,10 @@ export default function ModelAvailabilityBadge() {
: "bg-amber-500/10 border-amber-500/20 text-amber-500 hover:bg-amber-500/15"
}`}
>
<span className="material-symbols-outlined text-[14px]">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{isHealthy ? "verified" : "warning"}
</span>
{isHealthy
? "All models operational"
: `${unavailableCount} model${unavailableCount !== 1 ? "s" : ""} with issues`}
{isHealthy ? t("allModelsOperational") : t("modelsWithIssues", { count: unavailableCount })}
</button>
{/* Expanded popover */}
@@ -121,25 +123,26 @@ export default function ModelAvailabilityBadge() {
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isHealthy ? "#22c55e" : "#f59e0b" }}
aria-hidden="true"
>
{isHealthy ? "verified" : "warning"}
</span>
<span className="text-sm font-semibold text-text-main">Model Status</span>
<span className="text-sm font-semibold text-text-main">{t("modelStatus")}</span>
</div>
<button
onClick={fetchStatus}
className="p-1 rounded-lg hover:bg-surface text-text-muted hover:text-text-main transition-colors"
title="Refresh"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh
</span>
</button>
</div>
<div className="px-4 py-3 max-h-60 overflow-y-auto">
{isHealthy ? (
<p className="text-sm text-text-muted text-center py-2">
All models are responding normally.
</p>
<p className="text-sm text-text-muted text-center py-2">{t("allModelsNormal")}</p>
) : (
<div className="flex flex-col gap-2.5">
{Object.entries(byProvider).map(([provider, provModels]) => (
@@ -148,8 +151,10 @@ export default function ModelAvailabilityBadge() {
{provider}
</p>
<div className="flex flex-col gap-1">
{(provModels as any).map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
{provModels.map((m) => {
const status =
STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] ||
STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
@@ -160,6 +165,7 @@ export default function ModelAvailabilityBadge() {
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: status.color }}
aria-hidden="true"
>
{status.icon}
</span>
@@ -175,7 +181,7 @@ export default function ModelAvailabilityBadge() {
disabled={isClearing}
className="text-[10px] px-1.5! py-0.5! ml-2"
>
{isClearing ? "..." : "Clear"}
{isClearing ? t("clearing") : t("clearCooldown")}
</Button>
)}
</div>

View File

@@ -8,20 +8,24 @@
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityPanel() {
const [data, setData] = useState(null);
const t = useTranslations("providers");
const tc = useTranslations("common");
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: t("available") },
cooldown: { icon: "schedule", color: "#f59e0b", label: t("cooldown") },
unavailable: { icon: "error", color: "#ef4444", label: t("unavailable") },
unknown: { icon: "help", color: "#6b7280", label: t("unknown") },
};
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(null);
const [clearing, setClearing] = useState<string | null>(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
@@ -44,7 +48,7 @@ export default function ModelAvailabilityPanel() {
return () => clearInterval(interval);
}, [fetchStatus]);
const handleClearCooldown = async (provider, model) => {
const handleClearCooldown = async (provider: string, model: string) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
@@ -53,13 +57,13 @@ export default function ModelAvailabilityPanel() {
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
notify.success(t("cooldownCleared", { model }));
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
}
} catch {
notify.error("Failed to clear cooldown");
notify.error(t("failedClearCooldown"));
} finally {
setClearing(null);
}
@@ -69,8 +73,10 @@ export default function ModelAvailabilityPanel() {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">monitoring</span>
Loading model availability...
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
monitoring
</span>
{t("loadingAvailability")}
</div>
</Card>
);
@@ -78,18 +84,20 @@ export default function ModelAvailabilityPanel() {
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
data?.unavailableCount || models.filter((m: any) => m.status !== "available").length;
if (models.length === 0 || unavailableCount === 0) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
verified
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">All models operational</p>
<h3 className="text-lg font-semibold text-text-main">{t("modelAvailability")}</h3>
<p className="text-sm text-text-muted">{t("allModelsOperational")}</p>
</div>
</div>
</Card>
@@ -97,8 +105,8 @@ export default function ModelAvailabilityPanel() {
}
// Group by provider
const byProvider = {};
models.forEach((m) => {
const byProvider: Record<string, any[]> = {};
models.forEach((m: any) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
@@ -110,17 +118,27 @@ export default function ModelAvailabilityPanel() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">warning</span>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
warning
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<h3 className="text-lg font-semibold text-text-main">{t("modelAvailability")}</h3>
<p className="text-sm text-text-muted">
{unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues
{t("modelsWithIssues", { count: unavailableCount })}
</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchStatus} className="text-text-muted">
<span className="material-symbols-outlined text-[16px]">refresh</span>
<Button
size="sm"
variant="ghost"
onClick={fetchStatus}
className="text-text-muted"
title={tc("refresh")}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
</Button>
</div>
@@ -129,8 +147,9 @@ export default function ModelAvailabilityPanel() {
<div key={provider} className="border border-border/30 rounded-lg p-3">
<p className="text-sm font-medium text-text-main mb-2 capitalize">{provider}</p>
<div className="flex flex-col gap-1.5">
{(provModels as any).map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
{provModels.map((m) => {
const status =
STATUS_CONFIG[m.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
@@ -141,6 +160,7 @@ export default function ModelAvailabilityPanel() {
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
aria-hidden="true"
>
{status.icon}
</span>
@@ -156,7 +176,7 @@ export default function ModelAvailabilityPanel() {
</span>
{m.cooldownUntil && (
<span className="text-xs text-text-muted">
until {new Date(m.cooldownUntil).toLocaleTimeString()}
{t("until", { time: new Date(m.cooldownUntil).toLocaleTimeString() })}
</span>
)}
</div>
@@ -168,7 +188,7 @@ export default function ModelAvailabilityPanel() {
disabled={isClearing}
className="text-xs"
>
{isClearing ? "Clearing..." : "Clear"}
{isClearing ? t("clearing") : t("clearCooldown")}
</Button>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Card, Button, Input, Select, Toggle } from "@/shared/components";
import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config";
import { useTranslations } from "next-intl";
const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({
value: p.id,
@@ -19,6 +20,7 @@ const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({
export default function NewProviderPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const t = useTranslations("providers");
const [formData, setFormData] = useState({
provider: "",
authMethod: "api_key",
@@ -37,9 +39,9 @@ export default function NewProviderPage() {
const validate = () => {
const newErrors: any = {};
if (!formData.provider) newErrors.provider = "Please select a provider";
if (!formData.provider) newErrors.provider = t("selectProvider");
if (formData.authMethod === "api_key" && !formData.apiKey) {
newErrors.apiKey = "API Key is required";
newErrors.apiKey = t("apiKeyRequired");
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
@@ -61,10 +63,10 @@ export default function NewProviderPage() {
router.push("/dashboard/providers");
} else {
const data = await response.json();
setErrors({ submit: data.error || "Failed to create provider" });
setErrors({ submit: data.error || t("failedCreate") });
}
} catch (error) {
setErrors({ submit: "An error occurred. Please try again." });
setErrors({ submit: t("errorOccurred") });
} finally {
setLoading(false);
}
@@ -81,12 +83,10 @@ export default function NewProviderPage() {
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
Back to Providers
{t("backToProviders")}
</Link>
<h1 className="text-3xl font-semibold tracking-tight">Add New Provider</h1>
<p className="text-text-muted mt-2">
Configure a new AI provider to use with your applications.
</p>
<h1 className="text-3xl font-semibold tracking-tight">{t("addNewProvider")}</h1>
<p className="text-text-muted mt-2">{t("configureNewProvider")}</p>
</div>
{/* Form */}
@@ -94,11 +94,11 @@ export default function NewProviderPage() {
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Provider Selection */}
<Select
label="Provider"
label={t("providerLabel")}
options={providerOptions}
value={formData.provider}
onChange={(e) => handleChange("provider", e.target.value)}
placeholder="Select a provider"
placeholder={t("selectProvider")}
error={errors.provider as string}
required
/>
@@ -116,7 +116,7 @@ export default function NewProviderPage() {
</div>
<div>
<p className="font-medium">{selectedProvider.name}</p>
<p className="text-sm text-text-muted">Selected provider</p>
<p className="text-sm text-text-muted">{t("selectedProvider")}</p>
</div>
</Card.Section>
)}
@@ -124,7 +124,7 @@ export default function NewProviderPage() {
{/* Auth Method */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium">
Authentication Method <span className="text-red-500">*</span>
{t("authMethod")} <span className="text-red-500">*</span>
</label>
<div className="flex gap-3">
{authMethodOptions.map((method) => (
@@ -141,7 +141,9 @@ export default function NewProviderPage() {
<span className="material-symbols-outlined">
{method.value === "api_key" ? "key" : "lock"}
</span>
<span className="font-medium">{method.label}</span>
<span className="font-medium">
{method.value === "api_key" ? t("apiKeyLabel") : t("oauth2Label")}
</span>
</button>
))}
</div>
@@ -150,13 +152,13 @@ export default function NewProviderPage() {
{/* API Key Input */}
{formData.authMethod === "api_key" && (
<Input
label="API Key"
label={t("apiKeyLabel")}
type="password"
placeholder="Enter your API key"
placeholder={t("enterApiKey")}
value={formData.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
error={errors.apiKey as string}
hint="Your API key will be encrypted and stored securely."
hint={t("apiKeySecure")}
required
/>
)}
@@ -164,30 +166,28 @@ export default function NewProviderPage() {
{/* OAuth2 Button */}
{formData.authMethod === "oauth2" && (
<Card.Section className="">
<p className="text-sm text-text-muted mb-4">
Connect your account using OAuth2 authentication.
</p>
<p className="text-sm text-text-muted mb-4">{t("oauth2Desc")}</p>
<Button type="button" variant="secondary" icon="link">
Connect with OAuth2
{t("oauth2Connect")}
</Button>
</Card.Section>
)}
{/* Display Name */}
<Input
label="Display Name"
placeholder="e.g., Production API, Dev Environment"
label={t("displayName")}
placeholder={t("displayNamePlaceholder")}
value={formData.displayName}
onChange={(e) => handleChange("displayName", e.target.value)}
hint="Optional. A friendly name to identify this configuration."
hint={t("displayNameHint")}
/>
{/* Active Toggle */}
<Toggle
checked={formData.isActive}
onChange={(checked) => handleChange("isActive", checked)}
label="Active"
description="Enable this provider for use in your applications"
label={t("active")}
description={t("activeDescription")}
/>
{/* Error Message */}
@@ -201,11 +201,11 @@ export default function NewProviderPage() {
<div className="flex gap-3 pt-4 border-t border-border">
<Link href="/dashboard/providers" className="flex-1">
<Button type="button" variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</Link>
<Button type="submit" loading={loading} fullWidth className="flex-1">
Create Provider
{t("createProvider")}
</Button>
</div>
</form>

View File

@@ -23,19 +23,22 @@ import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import { useTranslations } from "next-intl";
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
function getStatusDisplay(connected, error, errorCode) {
function getStatusDisplay(connected, error, errorCode, t) {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
{t("connected", { count: connected })}
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
const errText = errorCode
? t("errorCount", { count: error, code: errorCode })
: t("errorCountNoCode", { count: error });
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
@@ -43,7 +46,7 @@ function getStatusDisplay(connected, error, errorCode) {
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
return <span className="text-text-muted">{t("noConnections")}</span>;
}
return parts;
}
@@ -89,14 +92,16 @@ function getConnectionErrorTag(connection) {
}
export default function ProvidersPage() {
const [connections, setConnections] = useState([]);
const [providerNodes, setProviderNodes] = useState([]);
const [connections, setConnections] = useState<any[]>([]);
const [providerNodes, setProviderNodes] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const [testingMode, setTestingMode] = useState<string | null>(null);
const [testResults, setTestResults] = useState<any>(null);
const notify = useNotificationStore();
const t = useTranslations("providers");
const tc = useTranslations("common");
useEffect(() => {
const fetchData = async () => {
@@ -194,12 +199,12 @@ export default function ProvidersPage() {
setTestResults(data);
if (data.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) notify.success(`All ${total} tests passed`);
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
if (failed === 0) notify.success(t("allTestsPassed", { total }));
else notify.warning(t("testSummary", { passed, failed, total }));
}
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Provider test failed");
setTestResults({ error: t("providerTestFailed") });
notify.error(t("providerTestFailed"));
} finally {
setTestingMode(null);
}
@@ -209,7 +214,7 @@ export default function ProvidersPage() {
.filter((node) => node.type === "openai-compatible")
.map((node) => ({
id: node.id,
name: node.name || "OpenAI Compatible",
name: node.name || t("openaiCompatibleName"),
color: "#10A37F",
textIcon: "OC",
apiType: node.apiType,
@@ -219,7 +224,7 @@ export default function ProvidersPage() {
.filter((node) => node.type === "anthropic-compatible")
.map((node) => ({
id: node.id,
name: node.name || "Anthropic Compatible",
name: node.name || t("anthropicCompatibleName"),
color: "#D97757",
textIcon: "AC",
}));
@@ -239,7 +244,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
OAuth Providers <span className="size-2.5 rounded-full bg-blue-500" title="OAuth" />
{t("oauthProviders")}{" "}
<span className="size-2.5 rounded-full bg-blue-500" title={t("oauthLabel")} />
</h2>
<div className="flex items-center gap-2">
<ModelAvailabilityBadge />
@@ -251,13 +257,13 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all OAuth connections"
aria-label="Test all OAuth connections"
title={t("testAllOAuth")}
aria-label={t("testAllOAuth")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "oauth" ? "sync" : "play_arrow"}
</span>
{testingMode === "oauth" ? "Testing..." : "Test All"}
{testingMode === "oauth" ? t("testing") : t("testAll")}
</button>
</div>
</div>
@@ -279,7 +285,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
Free Providers <span className="size-2.5 rounded-full bg-green-500" title="Free" />
{t("freeProviders")}{" "}
<span className="size-2.5 rounded-full bg-green-500" title={tc("free")} />
</h2>
<button
onClick={() => handleBatchTest("free")}
@@ -289,13 +296,13 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all Free connections"
aria-label="Test all Free provider connections"
title={t("testAllFree")}
aria-label={t("testAllFree")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "free" ? "sync" : "play_arrow"}
</span>
{testingMode === "free" ? "Testing..." : "Test All"}
{testingMode === "free" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -316,8 +323,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
API Key Providers{" "}
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
{t("apiKeyProviders")}{" "}
<span className="size-2.5 rounded-full bg-amber-500" title={t("apiKeyLabel")} />
</h2>
<button
onClick={() => handleBatchTest("apikey")}
@@ -327,13 +334,13 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all API Key connections"
aria-label="Test all API Key connections"
title={t("testAllApiKey")}
aria-label={t("testAllApiKey")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "apikey" ? "sync" : "play_arrow"}
</span>
{testingMode === "apikey" ? "Testing..." : "Test All"}
{testingMode === "apikey" ? t("testing") : t("testAll")}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -354,8 +361,8 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
API Key Compatible Providers{" "}
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
{t("compatibleProviders")}{" "}
<span className="size-2.5 rounded-full bg-orange-500" title={t("compatibleLabel")} />
</h2>
<div className="flex gap-2">
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
@@ -367,16 +374,16 @@ export default function ProvidersPage() {
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title="Test all Compatible connections"
title={t("testAllCompatible")}
>
<span className="material-symbols-outlined text-[14px]">
{testingMode === "compatible" ? "sync" : "play_arrow"}
</span>
{testingMode === "compatible" ? "Testing..." : "Test All"}
{testingMode === "compatible" ? t("testing") : t("testAll")}
</button>
)}
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
Add Anthropic Compatible
{t("addAnthropicCompatible")}
</Button>
<Button
size="sm"
@@ -385,7 +392,7 @@ export default function ProvidersPage() {
onClick={() => setShowAddCompatibleModal(true)}
className="!bg-white !text-black hover:!bg-gray-100"
>
Add OpenAI Compatible
{t("addOpenAICompatible")}
</Button>
</div>
</div>
@@ -394,10 +401,8 @@ export default function ProvidersPage() {
<span className="material-symbols-outlined text-[32px] text-text-muted mb-2">
extension
</span>
<p className="text-text-muted text-sm">No compatible providers added yet</p>
<p className="text-text-muted text-xs mt-1">
Use the buttons above to add OpenAI or Anthropic compatible endpoints
</p>
<p className="text-text-muted text-sm">{t("noCompatibleYet")}</p>
<p className="text-text-muted text-xs mt-1">{t("compatibleHint")}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -442,11 +447,11 @@ export default function ProvidersPage() {
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
<h3 className="font-semibold">Test Results</h3>
<h3 className="font-semibold">{t("testResults")}</h3>
<button
onClick={() => setTestResults(null)}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label="Close test results"
aria-label={tc("close")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
@@ -462,6 +467,8 @@ export default function ProvidersPage() {
}
function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const [imgError, setImgError] = useState(false);
@@ -471,7 +478,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
};
return (
<Link href={`/dashboard/providers/${providerId}`} className="group">
@@ -506,7 +518,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.oauth} shrink-0`}
title={dotLabels[authType] || "OAuth"}
title={dotLabels[authType] || t("oauthLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
@@ -514,12 +526,12 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, t)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
</>
)}
@@ -540,7 +552,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
@@ -573,6 +585,8 @@ ProviderCard.propTypes = {
// API Key providers - use image with textIcon fallback (same as OAuth providers)
function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
const { connected, error, errorCode, errorTime, allDisabled } = stats;
const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
const isAnthropicCompatible = providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
@@ -584,7 +598,12 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
apikey: "bg-amber-500",
compatible: "bg-orange-500",
};
const dotLabels = { free: "Free", oauth: "OAuth", apikey: "API Key", compatible: "Compatible" };
const dotLabels = {
free: tc("free"),
oauth: t("oauthLabel"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
};
// Determine icon path: OpenAI Compatible providers use specialized icons
const getIconPath = () => {
@@ -630,7 +649,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
{provider.name}
<span
className={`size-2 rounded-full ${dotColors[authType] || dotColors.apikey} shrink-0`}
title={dotLabels[authType] || "API Key"}
title={dotLabels[authType] || t("apiKeyLabel")}
/>
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
@@ -638,20 +657,20 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
<Badge variant="default" size="sm">
<span className="flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">pause_circle</span>
Disabled
{t("disabled")}
</span>
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, t)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses" ? "Responses" : "Chat"}
{provider.apiType === "responses" ? t("responses") : t("chat")}
</Badge>
)}
{isAnthropicCompatible && (
<Badge variant="default" size="sm">
Messages
{t("messages")}
</Badge>
)}
{errorTime && <span className="text-text-muted"> {errorTime}</span>}
@@ -674,7 +693,7 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle })
size="sm"
checked={!allDisabled}
onChange={() => {}}
title={allDisabled ? "Enable provider" : "Disable provider"}
title={allDisabled ? t("enableProvider") : t("disableProvider")}
/>
</div>
)}
@@ -707,6 +726,7 @@ ApiKeyProviderCard.propTypes = {
};
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
@@ -716,11 +736,11 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const apiTypeOptions = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses API" },
{ value: "chat", label: t("chatCompletions") },
{ value: "responses", label: t("responsesApi") },
];
useEffect(() => {
@@ -787,38 +807,38 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
};
return (
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
<Modal isOpen={isOpen} title={t("addOpenAICompatible")} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="OpenAI Compatible (Prod)"
hint="Required. A friendly label for this node."
placeholder={t("compatibleProdPlaceholder", { type: t("openai") })}
hint={t("nameHint")}
/>
<Input
label="Prefix"
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="oc-prod"
hint="Required. Used as the provider prefix for model IDs."
placeholder={t("openaiPrefixPlaceholder")}
hint={t("prefixHint")}
/>
<Select
label="API Type"
label={t("apiTypeLabel")}
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.openai.com/v1"
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
placeholder={t("openaiBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("openai") })}
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
@@ -830,13 +850,13 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
@@ -850,10 +870,10 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
submitting
}
>
{submitting ? "Creating..." : "Create"}
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -868,6 +888,7 @@ AddOpenAICompatibleModal.propTypes = {
};
function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
@@ -876,7 +897,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
useEffect(() => {
// Reset validation when modal opens
@@ -940,32 +961,32 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
};
return (
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
<Modal isOpen={isOpen} title={t("addAnthropicCompatible")} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Anthropic Compatible (Prod)"
hint="Required. A friendly label for this node."
placeholder={t("compatibleProdPlaceholder", { type: t("anthropic") })}
hint={t("nameHint")}
/>
<Input
label="Prefix"
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="ac-prod"
hint="Required. Used as the provider prefix for model IDs."
placeholder={t("anthropicPrefixPlaceholder")}
hint={t("prefixHint")}
/>
<Input
label="Base URL"
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.anthropic.com/v1"
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
placeholder={t("anthropicBaseUrlPlaceholder")}
hint={t("compatibleBaseUrlHint", { type: t("anthropic") })}
/>
<div className="flex gap-2">
<Input
label="API Key (for Check)"
label={t("apiKeyForCheck")}
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
@@ -977,13 +998,13 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
>
{validating ? "Checking..." : "Check"}
{validating ? t("checking") : t("check")}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
{validationResult === "success" ? t("valid") : t("invalid")}
</Badge>
)}
<div className="flex gap-2">
@@ -997,10 +1018,10 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
submitting
}
>
{submitting ? "Creating..." : "Create"}
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -1017,6 +1038,9 @@ AddAnthropicCompatibleModal.propTypes = {
// ─── Provider Test Results View (mirrors combo TestResultsView) ──────────────
function ProviderTestResultsView({ results }) {
const t = useTranslations("providers");
const tc = useTranslations("common");
if (results.error && !results.results) {
return (
<div className="text-center py-6">
@@ -1031,11 +1055,12 @@ function ProviderTestResultsView({ results }) {
const modeLabel =
{
oauth: "OAuth",
free: "Free",
apikey: "API Key",
provider: "Provider",
all: "All",
oauth: t("oauthLabel"),
free: tc("free"),
apikey: t("apiKeyLabel"),
compatible: t("compatibleLabel"),
provider: t("providerLabel"),
all: tc("all"),
}[mode] || mode;
return (
@@ -1043,16 +1068,18 @@ function ProviderTestResultsView({ results }) {
{/* Summary header */}
{summary && (
<div className="flex items-center gap-3 text-xs mb-1">
<span className="text-text-muted">{modeLabel} Test</span>
<span className="text-text-muted">{t("modeTest", { mode: modeLabel })}</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
{summary.passed} passed
{t("passedCount", { count: summary.passed })}
</span>
{summary.failed > 0 && (
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
{summary.failed} failed
{t("failedCount", { count: summary.failed })}
</span>
)}
<span className="text-text-muted ml-auto">{summary.total} tested</span>
<span className="text-text-muted ml-auto">
{t("testedCount", { count: summary.total })}
</span>
</div>
)}
@@ -1074,21 +1101,23 @@ function ProviderTestResultsView({ results }) {
<span className="text-text-muted ml-1.5">({r.provider})</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">{r.latencyMs}ms</span>
<span className="text-text-muted font-mono tabular-nums">
{t("millisecondsAbbr", { value: r.latencyMs })}
</span>
)}
<span
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
r.valid ? "bg-emerald-500/15 text-emerald-400" : "bg-red-500/15 text-red-400"
}`}
>
{r.valid ? "OK" : r.diagnosis?.type || "ERROR"}
{r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")}
</span>
</div>
))}
{items.length === 0 && (
<div className="text-center py-4 text-text-muted text-sm">
No active connections found for this group.
{t("noActiveConnectionsInGroup")}
</div>
)}
</div>

View File

@@ -1,11 +1,51 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Toggle } from "@/shared/components";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
export default function AppearanceTab() {
const { theme, setTheme, isDark } = useTheme();
const t = useTranslations("settings");
const [settings, setSettings] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const themeOptionLabels: Record<string, string> = {
light: t("themeLight"),
dark: t("themeDark"),
system: t("themeSystem"),
};
useEffect(() => {
fetch("/api/settings")
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error ${res.status}`);
}
return res.json();
})
.then((data) => {
setSettings(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const updateSetting = async (key: string, value: any) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (res.ok) {
setSettings((prev) => ({ ...prev, [key]: value }));
}
} catch (err) {
console.error(`Failed to update ${key}:`, err);
}
};
return (
<Card>
@@ -15,13 +55,13 @@ export default function AppearanceTab() {
palette
</span>
</div>
<h3 className="text-lg font-semibold">Appearance</h3>
<h3 className="text-lg font-semibold">{t("appearance")}</h3>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Dark Mode</p>
<p className="text-sm text-text-muted">Switch between light and dark themes</p>
<p className="font-medium">{t("darkMode")}</p>
<p className="text-sm text-text-muted">{t("switchThemes")}</p>
</div>
<Toggle checked={isDark} onChange={() => setTheme(isDark ? "light" : "dark")} />
</div>
@@ -29,7 +69,7 @@ export default function AppearanceTab() {
<div className="pt-4 border-t border-border">
<div
role="tablist"
aria-label="Theme selection"
aria-label={t("themeSelectionAria")}
className="inline-flex p-1 rounded-lg bg-black/5 dark:bg-white/5"
>
{["light", "dark", "system"].map((option) => (
@@ -48,11 +88,25 @@ export default function AppearanceTab() {
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
{option === "light" ? "light_mode" : option === "dark" ? "dark_mode" : "contrast"}
</span>
<span className="capitalize">{option}</span>
<span>{themeOptionLabels[option] || option}</span>
</button>
))}
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{t("hideHealthLogs")}</p>
<p className="text-sm text-text-muted">{t("hideHealthLogsDesc")}</p>
</div>
<Toggle
checked={settings.hideHealthCheckLogs === true}
onChange={() => updateSetting("hideHealthCheckLogs", !settings.hideHealthCheckLogs)}
disabled={loading}
/>
</div>
</div>
</div>
</Card>
);

View File

@@ -2,10 +2,12 @@
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function CacheStatsCard() {
const [cache, setCache] = useState(null);
const [flushing, setFlushing] = useState(false);
const t = useTranslations("settings");
const fetchStats = () => {
fetch("/api/cache/stats")
@@ -31,40 +33,40 @@ export default function CacheStatsCard() {
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">cached</span>
Prompt Cache
{t("promptCache")}
</h3>
<button
onClick={handleFlush}
disabled={flushing}
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{flushing ? "Flushing" : "Flush Cache"}
{flushing ? t("flushing") : t("flushCache")}
</button>
</div>
{cache ? (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Size</p>
<p className="text-text-muted">{t("size")}</p>
<p className="font-mono text-lg text-text-main">
{cache.size}/{cache.maxSize}
</p>
</div>
<div>
<p className="text-text-muted">Hit Rate</p>
<p className="text-text-muted">{t("hitRate")}</p>
<p className="font-mono text-lg text-text-main">{cache.hitRate?.toFixed(1) ?? 0}%</p>
</div>
<div>
<p className="text-text-muted">Hits</p>
<p className="text-text-muted">{t("hits")}</p>
<p className="font-mono text-text-main">{cache.hits ?? 0}</p>
</div>
<div>
<p className="text-text-muted">Evictions</p>
<p className="text-text-muted">{t("evictions")}</p>
<p className="font-mono text-text-main">{cache.evictions ?? 0}</p>
</div>
</div>
) : (
<p className="text-sm text-text-muted">Loading cache stats</p>
<p className="text-sm text-text-muted">{t("loadingCacheStats")}</p>
)}
</Card>
);

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
export default function ComboDefaultsTab() {
const [comboDefaults, setComboDefaults] = useState<any>({
@@ -18,6 +19,22 @@ export default function ComboDefaultsTab() {
const [providerOverrides, setProviderOverrides] = useState<any>({});
const [newOverrideProvider, setNewOverrideProvider] = useState("");
const [saving, setSaving] = useState(false);
const t = useTranslations("settings");
const tc = useTranslations("common");
const strategyOptions = [
{ value: "priority", label: t("priority"), icon: "sort" },
{ value: "weighted", label: t("weighted"), icon: "percent" },
{ value: "round-robin", label: t("roundRobin"), icon: "autorenew" },
{ value: "random", label: t("random"), icon: "shuffle" },
{ value: "least-used", label: t("leastUsed"), icon: "low_priority" },
{ value: "cost-optimized", label: t("costOpt"), icon: "savings" },
];
const numericSettings = [
{ key: "maxRetries", label: t("maxRetriesLabel"), min: 0, max: 5 },
{ key: "retryDelayMs", label: t("retryDelayLabel"), min: 500, max: 10000, step: 500 },
{ key: "timeoutMs", label: t("timeoutLabel"), min: 5000, max: 300000, step: 5000 },
{ key: "maxComboDepth", label: t("maxNestingDepth"), min: 1, max: 10 },
];
useEffect(() => {
fetch("/api/settings/combo-defaults")
@@ -67,31 +84,22 @@ export default function ComboDefaultsTab() {
tune
</span>
</div>
<h3 className="text-lg font-semibold">Combo Defaults</h3>
<span className="text-xs text-text-muted ml-auto">Global combo configuration</span>
<h3 className="text-lg font-semibold">{t("comboDefaultsTitle")}</h3>
<span className="text-xs text-text-muted ml-auto">{t("globalComboConfig")}</span>
</div>
<div className="flex flex-col gap-4">
{/* Default Strategy */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Default Strategy</p>
<p className="text-xs text-text-muted">
Applied to new combos without explicit strategy
</p>
<p className="font-medium text-sm">{t("defaultStrategy")}</p>
<p className="text-xs text-text-muted">{t("defaultStrategyDesc")}</p>
</div>
<div
role="tablist"
aria-label="Combo strategy"
aria-label={t("comboStrategyAria")}
className="grid grid-cols-3 gap-1 p-0.5 rounded-md bg-black/5 dark:bg-white/5"
>
{[
{ value: "priority", label: "Priority", icon: "sort" },
{ value: "weighted", label: "Weighted", icon: "percent" },
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
{ value: "random", label: "Random", icon: "shuffle" },
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
].map((s) => (
{strategyOptions.map((s) => (
<button
key={s.value}
role="tab"
@@ -113,12 +121,7 @@ export default function ComboDefaultsTab() {
{/* Numeric settings */}
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
{[
{ key: "maxRetries", label: "Max Retries", min: 0, max: 5 },
{ key: "retryDelayMs", label: "Retry Delay (ms)", min: 500, max: 10000, step: 500 },
{ key: "timeoutMs", label: "Timeout (ms)", min: 5000, max: 300000, step: 5000 },
{ key: "maxComboDepth", label: "Max Nesting Depth", min: 1, max: 10 },
].map(({ key, label, min, max, step }) => (
{numericSettings.map(({ key, label, min, max, step }) => (
<Input
key={key}
label={label}
@@ -139,7 +142,7 @@ export default function ComboDefaultsTab() {
{comboDefaults.strategy === "round-robin" && (
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-border/50">
<Input
label="Concurrency / Model"
label={t("concurrencyPerModel")}
type="number"
min={1}
max={20}
@@ -154,7 +157,7 @@ export default function ComboDefaultsTab() {
className="text-sm"
/>
<Input
label="Queue Timeout (ms)"
label={t("queueTimeout")}
type="number"
min={1000}
max={120000}
@@ -176,8 +179,8 @@ export default function ComboDefaultsTab() {
<div className="flex flex-col gap-3 pt-3 border-t border-border/50">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Health Check</p>
<p className="text-xs text-text-muted">Pre-check provider availability</p>
<p className="font-medium text-sm">{t("healthCheck")}</p>
<p className="text-xs text-text-muted">{t("healthCheckDesc")}</p>
</div>
<Toggle
checked={comboDefaults.healthCheckEnabled !== false}
@@ -191,8 +194,8 @@ export default function ComboDefaultsTab() {
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Track Metrics</p>
<p className="text-xs text-text-muted">Record per-combo request metrics</p>
<p className="font-medium text-sm">{t("trackMetrics")}</p>
<p className="text-xs text-text-muted">{t("trackMetricsDesc")}</p>
</div>
<Toggle
checked={comboDefaults.trackMetrics !== false}
@@ -205,10 +208,8 @@ export default function ComboDefaultsTab() {
{/* Provider Overrides */}
<div className="pt-3 border-t border-border/50">
<p className="font-medium text-sm mb-2">Provider Overrides</p>
<p className="text-xs text-text-muted mb-3">
Override timeout and retries per provider. Provider settings override global defaults.
</p>
<p className="font-medium text-sm mb-2">{t("providerOverrides")}</p>
<p className="text-xs text-text-muted mb-3">{t("providerOverridesDesc")}</p>
{Object.entries(providerOverrides).map(([provider, config]: [string, any]) => (
<div
@@ -228,9 +229,9 @@ export default function ComboDefaultsTab() {
}))
}
className="text-xs w-16"
aria-label={`${provider} max retries`}
aria-label={t("providerMaxRetriesAria", { provider })}
/>
<span className="text-[10px] text-text-muted">retries</span>
<span className="text-[10px] text-text-muted">{t("retries")}</span>
<Input
type="number"
min="5000"
@@ -247,13 +248,13 @@ export default function ComboDefaultsTab() {
}))
}
className="text-xs w-24"
aria-label={`${provider} timeout ms`}
aria-label={t("providerTimeoutAria", { provider })}
/>
<span className="text-[10px] text-text-muted">ms</span>
<span className="text-[10px] text-text-muted">{t("ms")}</span>
<button
onClick={() => removeProviderOverride(provider)}
className="ml-auto text-red-400 hover:text-red-500 transition-colors"
aria-label={`Remove ${provider} override`}
aria-label={t("removeProviderOverrideAria", { provider })}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
close
@@ -265,12 +266,12 @@ export default function ComboDefaultsTab() {
<div className="flex items-center gap-2 mt-2">
<Input
type="text"
placeholder="e.g. google, openai..."
placeholder={t("newProviderNamePlaceholder")}
value={newOverrideProvider}
onChange={(e) => setNewOverrideProvider(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addProviderOverride()}
className="text-xs flex-1"
aria-label="New provider name"
aria-label={t("newProviderNameAria")}
/>
<Button
variant="outline"
@@ -278,7 +279,7 @@ export default function ComboDefaultsTab() {
onClick={addProviderOverride}
disabled={!newOverrideProvider.trim()}
>
Add
{tc("add")}
</Button>
</div>
</div>
@@ -286,7 +287,7 @@ export default function ComboDefaultsTab() {
{/* Save */}
<div className="pt-3 border-t border-border/50">
<Button variant="primary" size="sm" onClick={saveComboDefaults} loading={saving}>
Save Combo Defaults
{t("saveComboDefaults")}
</Button>
</div>
</div>

View File

@@ -3,13 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const ALL_COLUMNS = [
{ key: "timestamp", label: "Time" },
{ key: "action", label: "Action" },
{ key: "actor", label: "Actor" },
{ key: "details", label: "Details" },
];
import { useTranslations } from "next-intl";
export default function ComplianceTab() {
const [logs, setLogs] = useState([]);
@@ -23,6 +17,13 @@ export default function ComplianceTab() {
details: true,
});
const notify = useNotificationStore();
const t = useTranslations("settings");
const allColumns = [
{ key: "timestamp", label: t("time") },
{ key: "action", label: t("action") },
{ key: "actor", label: t("actor") },
{ key: "details", label: t("details") },
];
useEffect(() => {
fetch("/api/compliance/audit-log?limit=100")
@@ -33,7 +34,7 @@ export default function ComplianceTab() {
})
.catch(() => {
setLoading(false);
notify.error("Failed to load audit log");
notify.error(t("failedLoadAuditLog"));
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -54,56 +55,59 @@ export default function ComplianceTab() {
return true;
});
const columns = ALL_COLUMNS.filter((c) => visibleCols[c.key]);
const columns = allColumns.filter((c) => visibleCols[c.key]);
const handleToggleCol = useCallback((key) => {
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const renderCell = useCallback((row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || "system"}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
}, []);
const renderCell = useCallback(
(row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || t("systemActor")}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
},
[t]
);
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">policy</span>
Audit Log
{t("auditLog")}
</h3>
<ColumnToggle columns={ALL_COLUMNS} visible={visibleCols} onToggle={handleToggleCol} />
<ColumnToggle columns={allColumns} visible={visibleCols} onToggle={handleToggleCol} />
</div>
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search audit logs..."
placeholder={t("searchAuditLogs")}
filters={[
{ key: "action", label: "Action", options: actionOptions },
{ key: "actor", label: "Actor", options: actorOptions },
{ key: "action", label: t("action"), options: actionOptions },
{ key: "actor", label: t("actor"), options: actorOptions },
]}
activeFilters={filters}
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}
@@ -118,7 +122,7 @@ export default function ComplianceTab() {
loading={loading}
maxHeight="400px"
emptyIcon="📋"
emptyMessage="No audit events found"
emptyMessage={t("noAuditEvents")}
/>
</Card>
);

View File

@@ -11,6 +11,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
const CHAIN_COLORS = [
"#6366f1",
@@ -31,6 +32,8 @@ export default function FallbackChainsEditor() {
const [newProviders, setNewProviders] = useState("");
const [saving, setSaving] = useState(false);
const notify = useNotificationStore();
const t = useTranslations("settings");
const tc = useTranslations("common");
const fetchChains = useCallback(async () => {
try {
@@ -52,7 +55,7 @@ export default function FallbackChainsEditor() {
const handleCreate = async () => {
if (!newModel.trim() || !newProviders.trim()) {
notify.warning("Please fill model name and providers");
notify.warning(t("fillModelAndProviders"));
return;
}
@@ -63,7 +66,7 @@ export default function FallbackChainsEditor() {
.map((provider, i) => ({ provider, priority: i + 1, enabled: true }));
if (providers.length === 0) {
notify.warning("Add at least one provider");
notify.warning(t("addAtLeastOneProvider"));
return;
}
@@ -75,23 +78,23 @@ export default function FallbackChainsEditor() {
body: JSON.stringify({ model: newModel.trim(), chain: providers }),
});
if (res.ok) {
notify.success(`Chain created for ${newModel.trim()}`);
notify.success(t("chainCreated", { model: newModel.trim() }));
setNewModel("");
setNewProviders("");
setShowCreate(false);
await fetchChains();
} else {
notify.error("Failed to create chain");
notify.error(t("failedCreateChain"));
}
} catch {
notify.error("Failed to create chain");
notify.error(t("failedCreateChain"));
} finally {
setSaving(false);
}
};
const handleDelete = async (model) => {
if (!confirm(`Delete fallback chain for "${model}"?`)) return;
if (!confirm(t("deleteChainConfirm", { model }))) return;
try {
const res = await fetch("/api/fallback/chains", {
method: "DELETE",
@@ -99,13 +102,13 @@ export default function FallbackChainsEditor() {
body: JSON.stringify({ model }),
});
if (res.ok) {
notify.success(`Chain deleted for ${model}`);
notify.success(t("chainDeleted", { model }));
await fetchChains();
} else {
notify.error("Failed to delete chain");
notify.error(t("failedDeleteChain"));
}
} catch {
notify.error("Failed to delete chain");
notify.error(t("failedDeleteChain"));
}
};
@@ -114,7 +117,7 @@ export default function FallbackChainsEditor() {
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">timeline</span>
Loading fallback chains...
{t("loadingFallbackChains")}
</div>
</Card>
);
@@ -129,11 +132,11 @@ export default function FallbackChainsEditor() {
<span className="material-symbols-outlined text-[20px]">timeline</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Fallback Chains</h3>
<p className="text-sm text-text-muted">Define provider fallback order per model</p>
<h3 className="text-lg font-semibold">{t("fallbackChainsTitle")}</h3>
<p className="text-sm text-text-muted">{t("fallbackChainsDesc")}</p>
</div>
<Button size="sm" variant="primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? "Cancel" : "+ Add Chain"}
{showCreate ? tc("cancel") : t("addChain")}
</Button>
</div>
@@ -142,20 +145,20 @@ export default function FallbackChainsEditor() {
<div className="mx-6 p-4 rounded-lg border border-border/30 bg-surface/20 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label="Model Name"
placeholder="claude-sonnet-4-20250514"
label={t("modelName")}
placeholder={t("modelNamePlaceholder")}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
/>
<Input
label="Providers (comma-separated, in priority order)"
placeholder="anthropic, openai, gemini"
label={t("providersCommaSeparated")}
placeholder={t("providersCommaSeparatedPlaceholder")}
value={newProviders}
onChange={(e) => setNewProviders(e.target.value)}
/>
</div>
<Button variant="primary" size="sm" onClick={handleCreate} loading={saving}>
Create Chain
{t("createChain")}
</Button>
</div>
)}
@@ -165,8 +168,8 @@ export default function FallbackChainsEditor() {
{chainEntries.length === 0 ? (
<EmptyState
icon="timeline"
title="No Fallback Chains"
description="Create a chain to define provider fallback order for a model."
title={t("noFallbackChains")}
description={t("noFallbackChainsDesc")}
/>
) : (
<div className="flex flex-col gap-2">
@@ -201,7 +204,7 @@ export default function FallbackChainsEditor() {
<button
onClick={() => handleDelete(model)}
className="text-text-muted hover:text-red-400 transition-colors ml-2"
title="Delete chain"
title={t("deleteChain")}
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>

View File

@@ -1,20 +1,28 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { Card, Button, Input } from "@/shared/components";
import { useTranslations } from "next-intl";
const MODES = [
{ value: "disabled", label: "Disabled", icon: "block" },
{ value: "blacklist", label: "Blacklist", icon: "do_not_disturb" },
{ value: "whitelist", label: "Whitelist", icon: "verified_user" },
{ value: "whitelist-priority", label: "WL Priority", icon: "priority_high" },
{ value: "disabled", labelKey: "ipModeDisabled", icon: "block" },
{ value: "blacklist", labelKey: "ipModeBlacklist", icon: "do_not_disturb" },
{ value: "whitelist", labelKey: "ipModeWhitelist", icon: "verified_user" },
{ value: "whitelist-priority", labelKey: "ipModeWhitelistPriority", icon: "priority_high" },
];
export default function IPFilterSection() {
const [config, setConfig] = useState({ enabled: false, mode: "blacklist", blacklist: [], whitelist: [], tempBans: [] });
const [config, setConfig] = useState({
enabled: false,
mode: "blacklist",
blacklist: [],
whitelist: [],
tempBans: [],
});
const [loading, setLoading] = useState(true);
const [newIP, setNewIP] = useState("");
const [listTarget, setListTarget] = useState("blacklist"); // "blacklist" | "whitelist"
const [listTarget, setListTarget] = useState("blacklist");
const t = useTranslations("settings");
useEffect(() => {
loadConfig();
@@ -24,7 +32,8 @@ export default function IPFilterSection() {
try {
const res = await fetch("/api/settings/ip-filter");
if (res.ok) setConfig(await res.json());
} catch {} finally {
} catch {
} finally {
setLoading(false);
}
};
@@ -40,8 +49,6 @@ export default function IPFilterSection() {
} catch {}
};
const toggleEnabled = () => updateConfig({ enabled: !config.enabled });
const setMode = (mode) => {
if (mode === "disabled") {
updateConfig({ enabled: false });
@@ -75,8 +82,8 @@ export default function IPFilterSection() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">IP Access Control</h3>
<p className="text-sm text-text-muted">Block or allow specific IP addresses</p>
<h3 className="text-lg font-semibold">{t("ipAccessControl")}</h3>
<p className="text-sm text-text-muted">{t("ipAccessControlDesc")}</p>
</div>
</div>
@@ -93,11 +100,17 @@ export default function IPFilterSection() {
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<span className={`material-symbols-outlined text-[20px] ${
activeMode === m.value ? "text-red-400" : "text-text-muted"
}`}>{m.icon}</span>
<span className={`text-xs font-medium ${activeMode === m.value ? "text-red-400" : "text-text-muted"}`}>
{m.label}
<span
className={`material-symbols-outlined text-[20px] ${
activeMode === m.value ? "text-red-400" : "text-text-muted"
}`}
>
{m.icon}
</span>
<span
className={`text-xs font-medium ${activeMode === m.value ? "text-red-400" : "text-text-muted"}`}
>
{t(m.labelKey)}
</span>
</button>
))}
@@ -109,8 +122,8 @@ export default function IPFilterSection() {
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Add IP Address"
placeholder="192.168.1.0/24 or 10.0.*.*"
label={t("addIpAddress")}
placeholder={t("ipAddressPlaceholder")}
value={newIP}
onChange={(e) => setNewIP(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addIP()}
@@ -120,16 +133,22 @@ export default function IPFilterSection() {
<Button
size="sm"
variant={listTarget === "blacklist" ? "danger" : "secondary"}
onClick={() => { setListTarget("blacklist"); if (newIP.trim()) addIP(); }}
onClick={() => {
setListTarget("blacklist");
if (newIP.trim()) addIP();
}}
>
+ Block
{t("block")}
</Button>
<Button
size="sm"
variant={listTarget === "whitelist" ? "primary" : "secondary"}
onClick={() => { setListTarget("whitelist"); if (newIP.trim()) addIP(); }}
onClick={() => {
setListTarget("whitelist");
if (newIP.trim()) addIP();
}}
>
+ Allow
{t("allow")}
</Button>
</div>
</div>
@@ -138,7 +157,7 @@ export default function IPFilterSection() {
{config.blacklist.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Blocked ({config.blacklist.length})
{t("blocked", { count: config.blacklist.length })}
</p>
<div className="flex flex-wrap gap-1.5">
{config.blacklist.map((ip) => (
@@ -148,7 +167,10 @@ export default function IPFilterSection() {
bg-red-500/10 text-red-400 border border-red-500/20"
>
{ip}
<button onClick={() => removeIP(ip, "blacklist")} className="hover:text-red-300">
<button
onClick={() => removeIP(ip, "blacklist")}
className="hover:text-red-300"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
</span>
@@ -161,7 +183,7 @@ export default function IPFilterSection() {
{config.whitelist.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Allowed ({config.whitelist.length})
{t("allowed", { count: config.whitelist.length })}
</p>
<div className="flex flex-wrap gap-1.5">
{config.whitelist.map((ip) => (
@@ -171,7 +193,10 @@ export default function IPFilterSection() {
bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
>
{ip}
<button onClick={() => removeIP(ip, "whitelist")} className="hover:text-emerald-300">
<button
onClick={() => removeIP(ip, "whitelist")}
className="hover:text-emerald-300"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
</span>
@@ -184,7 +209,7 @@ export default function IPFilterSection() {
{config.tempBans.length > 0 && (
<div>
<p className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Temporary Bans ({config.tempBans.length})
{t("temporaryBans", { count: config.tempBans.length })}
</p>
<div className="flex flex-col gap-1.5">
{config.tempBans.map((ban) => (
@@ -199,9 +224,12 @@ export default function IPFilterSection() {
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted tabular-nums">
{Math.ceil(ban.remainingMs / 60000)}m left
{t("minLeft", { min: Math.ceil(ban.remainingMs / 60000) })}
</span>
<button onClick={() => removeBan(ban.ip)} className="text-text-muted hover:text-orange-400">
<button
onClick={() => removeBan(ban.ip)}
className="text-text-muted hover:text-orange-400"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>

View File

@@ -11,6 +11,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
@@ -23,6 +24,7 @@ export default function PoliciesPanel() {
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const t = useTranslations("settings");
const fetchPolicies = useCallback(async () => {
try {
@@ -56,10 +58,10 @@ export default function PoliciesPanel() {
notify.success(`Unlocked: ${identifier}`);
await fetchPolicies();
} else {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
}
} catch {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
} finally {
setUnlocking(null);
}
@@ -70,7 +72,7 @@ export default function PoliciesPanel() {
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">security</span>
Loading policies...
{t("loadingPolicies")}
</div>
</Card>
);
@@ -88,10 +90,8 @@ export default function PoliciesPanel() {
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
<h3 className="text-lg font-semibold text-text-main">{t("policiesCircuitBreakers")}</h3>
<p className="text-sm text-text-muted">{t("allOperational")}</p>
</div>
</div>
</Card>
@@ -106,8 +106,8 @@ export default function PoliciesPanel() {
<span className="material-symbols-outlined text-[20px]">gpp_maybe</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">Active issues detected</p>
<h3 className="text-lg font-semibold text-text-main">{t("policiesCircuitBreakers")}</h3>
<p className="text-sm text-text-muted">{t("activeIssuesDetected")}</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
@@ -118,7 +118,7 @@ export default function PoliciesPanel() {
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("circuitBreakers")}</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
@@ -162,7 +162,7 @@ export default function PoliciesPanel() {
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("lockedIdentifiers")}</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
@@ -187,7 +187,7 @@ export default function PoliciesPanel() {
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
</Button>
</div>
);

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const PRICING_FIELDS = ["input", "output", "cached", "reasoning", "cache_creation"];
const FIELD_LABELS = {
@@ -22,6 +23,7 @@ export default function PricingTab() {
const [expandedProviders, setExpandedProviders] = useState(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [editedProviders, setEditedProviders] = useState(new Set());
const t = useTranslations("settings");
// Load catalog + pricing
useEffect(() => {
@@ -50,9 +52,7 @@ export default function PricingTab() {
.map(([alias, info]: [string, any]) => ({
alias,
...info,
pricedModels: pricingData[alias]
? Object.keys(pricingData[alias]).length
: 0,
pricedModels: pricingData[alias] ? Object.keys(pricingData[alias]).length : 0,
}))
.sort((a, b) => b.modelCount - a.modelCount);
return providers;
@@ -66,11 +66,7 @@ export default function PricingTab() {
(p) =>
p.alias.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q) ||
p.models.some(
(m) =>
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q)
)
p.models.some((m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q))
);
}, [allProviders, searchQuery]);
@@ -97,23 +93,20 @@ export default function PricingTab() {
});
}, []);
const handlePricingChange = useCallback(
(provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
const handlePricingChange = useCallback((provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
},
[]
);
setPricingData((prev) => {
const next = { ...prev };
if (!next[provider]) next[provider] = {};
if (!next[provider][model])
next[provider][model] = { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 };
next[provider][model] = { ...next[provider][model], [field]: numValue };
return next;
});
setEditedProviders((prev) => new Set(prev).add(provider));
}, []);
const saveProvider = useCallback(
async (providerAlias) => {
@@ -148,36 +141,25 @@ export default function PricingTab() {
[pricingData]
);
const resetProvider = useCallback(
async (providerAlias) => {
if (
!confirm(
`Reset all pricing for ${providerAlias.toUpperCase()} to defaults?`
)
)
return;
try {
const response = await fetch(
`/api/pricing?provider=${providerAlias}`,
{ method: "DELETE" }
);
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} reset to defaults`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
} catch (error) {
setSaveStatus(`❌ Reset failed: ${error.message}`);
const resetProvider = useCallback(async (providerAlias) => {
if (!confirm(t("resetPricingConfirm", { provider: providerAlias.toUpperCase() }))) return;
try {
const response = await fetch(`/api/pricing?provider=${providerAlias}`, { method: "DELETE" });
if (response.ok) {
const updated = await response.json();
setPricingData(updated);
setSaveStatus(`🔄 ${providerAlias.toUpperCase()} reset to defaults`);
setEditedProviders((prev) => {
const next = new Set(prev);
next.delete(providerAlias);
return next;
});
setTimeout(() => setSaveStatus(""), 3000);
}
},
[]
);
} catch (error) {
setSaveStatus(`❌ Reset failed: ${error.message}`);
}
}, []);
const selectProviderFilter = useCallback((alias) => {
setSelectedProvider((prev) => (prev === alias ? null : alias));
@@ -194,9 +176,7 @@ export default function PricingTab() {
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-text-muted animate-pulse">
Loading pricing data...
</div>
<div className="text-text-muted animate-pulse">{t("loadingPricing")}</div>
</div>
);
}
@@ -206,30 +186,21 @@ export default function PricingTab() {
{/* Header + Stats */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h2 className="text-xl font-bold">Model Pricing</h2>
<p className="text-text-muted text-sm mt-1">
Configure cost rates per model All rates in{" "}
<strong>$/1M tokens</strong>
</p>
<h2 className="text-xl font-bold">{t("modelPricing")}</h2>
<p className="text-text-muted text-sm mt-1">{t("modelPricingDesc")}</p>
</div>
<div className="flex gap-3 text-sm">
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">
Providers
</div>
<div className="text-text-muted text-xs font-semibold">{t("providers")}</div>
<div className="text-lg font-bold">{stats.providers}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">
Registry
</div>
<div className="text-text-muted text-xs font-semibold">{t("registry")}</div>
<div className="text-lg font-bold">{stats.totalModels}</div>
</div>
<div className="bg-bg-subtle rounded-lg px-3 py-2 text-center">
<div className="text-text-muted text-xs font-semibold">Priced</div>
<div className="text-lg font-bold text-success">
{stats.pricedCount as number}
</div>
<div className="text-text-muted text-xs font-semibold">{t("priced")}</div>
<div className="text-lg font-bold text-success">{stats.pricedCount as number}</div>
</div>
</div>
</div>
@@ -249,7 +220,7 @@ export default function PricingTab() {
</span>
<input
type="text"
placeholder="Search providers or models..."
placeholder={t("searchProvidersModels")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-bg-base border border-border rounded-lg focus:outline-none focus:border-primary text-sm"
@@ -261,7 +232,7 @@ export default function PricingTab() {
className="px-3 py-2 text-xs bg-primary/10 text-primary border border-primary/20 rounded-lg hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<span className="material-symbols-outlined text-sm">close</span>
{selectedProvider.toUpperCase()} Show All
{selectedProvider.toUpperCase()} {t("showAll")}
</button>
)}
</div>
@@ -276,12 +247,11 @@ export default function PricingTab() {
selectedProvider === p.alias
? "bg-primary text-white shadow-sm"
: editedProviders.has(p.alias)
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
? "bg-yellow-500/15 text-yellow-400 border border-yellow-500/30"
: "bg-bg-subtle text-text-muted hover:bg-bg-hover border border-transparent"
}`}
>
{p.alias.toUpperCase()}{" "}
<span className="opacity-60">({p.modelCount})</span>
{p.alias.toUpperCase()} <span className="opacity-60">({p.modelCount})</span>
</button>
))}
</div>
@@ -306,33 +276,22 @@ export default function PricingTab() {
))}
{displayProviders.length === 0 && (
<div className="text-center py-12 text-text-muted">
No providers match your search.
</div>
<div className="text-center py-12 text-text-muted">{t("noProvidersMatch")}</div>
)}
</div>
{/* Info Box */}
<Card className="p-4 mt-2">
<h3 className="text-sm font-semibold mb-2">
<span className="material-symbols-outlined text-sm align-middle mr-1">
info
</span>
How Pricing Works
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
{t("howPricingWorks")}
</h3>
<div className="text-xs text-text-muted space-y-1">
<p>
<strong>Input</strong>: tokens sent to the model {" "}
<strong>Output</strong>: tokens generated {" "}
<strong>Cached</strong>: reused input (~50% of input rate) {" "}
<strong>Reasoning</strong>: thinking tokens (falls back to Output) {" "}
<strong>Cache Write</strong>: creating cache entries (falls back to
Input)
</p>
<p>
Cost = (input × input_rate) + (output × output_rate) + (cached ×
cached_rate) per million tokens.
{t("pricingDescInput")} {t("pricingDescOutput")} {t("pricingDescCached")} {" "}
{t("pricingDescReasoning")} {t("pricingDescCacheWrite")}
</p>
<p>{t("pricingDescFormula")}</p>
</div>
</Card>
</div>
@@ -352,20 +311,19 @@ function ProviderSection({
onReset,
saving,
}) {
const t = useTranslations("settings");
const pricedCount = Object.keys(pricingData).length;
const authBadge =
provider.authType === "oauth"
? "OAuth"
: provider.authType === "apikey"
? "API Key"
: provider.authType;
? "API Key"
: provider.authType;
return (
<div
className={`border rounded-lg overflow-hidden transition-colors ${
isEdited
? "border-yellow-500/40 bg-yellow-500/5"
: "border-border"
isEdited ? "border-yellow-500/40 bg-yellow-500/5" : "border-border"
}`}
>
{/* Header (click to expand) */}
@@ -385,9 +343,7 @@ function ProviderSection({
<span className="font-semibold text-sm">
{provider.id.charAt(0).toUpperCase() + provider.id.slice(1)}
</span>
<span className="text-text-muted text-xs ml-2">
({provider.alias.toUpperCase()})
</span>
<span className="text-text-muted text-xs ml-2">({provider.alias.toUpperCase()})</span>
</div>
<span className="px-1.5 py-0.5 bg-bg-subtle text-text-muted text-[10px] rounded uppercase font-semibold">
{authBadge}
@@ -397,11 +353,7 @@ function ProviderSection({
</span>
</div>
<div className="flex items-center gap-3">
{isEdited && (
<span className="text-yellow-500 text-xs font-medium">
unsaved
</span>
)}
{isEdited && <span className="text-yellow-500 text-xs font-medium">{t("unsaved")}</span>}
<span className="text-text-muted text-xs">
{pricedCount}/{provider.modelCount} priced
</span>
@@ -426,8 +378,7 @@ function ProviderSection({
{/* Actions bar */}
<div className="flex items-center justify-between px-4 py-2 bg-bg-subtle/50">
<span className="text-xs text-text-muted">
{provider.modelCount} models {" "}
{pricedCount} with pricing configured
{provider.modelCount} models {pricedCount} with pricing configured
</span>
<div className="flex items-center gap-2">
<button
@@ -437,7 +388,7 @@ function ProviderSection({
}}
className="px-2.5 py-1 text-[11px] text-red-400 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
>
Reset Defaults
{t("resetDefaults")}
</button>
<button
onClick={(e) => {
@@ -447,7 +398,7 @@ function ProviderSection({
disabled={saving || !isEdited}
className="px-2.5 py-1 text-[11px] bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-40"
>
{saving ? "Saving..." : "Save Provider"}
{saving ? t("saving") : t("saveProvider")}
</button>
</div>
</div>
@@ -457,12 +408,9 @@ function ProviderSection({
<table className="w-full text-sm">
<thead className="text-[11px] text-text-muted uppercase bg-bg-subtle/30">
<tr>
<th className="px-4 py-2 text-left font-semibold">Model</th>
<th className="px-4 py-2 text-left font-semibold">{t("model")}</th>
{PRICING_FIELDS.map((field) => (
<th
key={field}
className="px-2 py-2 text-right font-semibold w-24"
>
<th key={field} className="px-2 py-2 text-right font-semibold w-24">
{FIELD_LABELS[field]}
</th>
))}
@@ -474,9 +422,7 @@ function ProviderSection({
key={model.id}
model={model}
pricing={pricingData[model.id]}
onPricingChange={(field, value) =>
onPricingChange(model.id, field, value)
}
onPricingChange={(field, value) => onPricingChange(model.id, field, value)}
/>
))}
</tbody>
@@ -498,9 +444,7 @@ function ModelRow({ model, pricing, onPricingChange }) {
<td className="px-4 py-1.5">
<div className="flex items-center gap-2">
<span
className={`w-1.5 h-1.5 rounded-full ${
hasPricing ? "bg-success" : "bg-text-muted/30"
}`}
className={`w-1.5 h-1.5 rounded-full ${hasPricing ? "bg-success" : "bg-text-muted/30"}`}
/>
<span className="font-medium text-xs">{model.name}</span>
{model.custom && (

View File

@@ -2,11 +2,14 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ProxyConfigModal } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function ProxyTab() {
const [proxyModalOpen, setProxyModalOpen] = useState(false);
const [globalProxy, setGlobalProxy] = useState(null);
const mountedRef = useRef(true);
const t = useTranslations("settings");
const tc = useTranslations("common");
const loadGlobalProxy = async () => {
try {
@@ -44,12 +47,9 @@ export default function ProxyTab() {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
vpn_lock
</span>
<h2 className="text-lg font-bold">Global Proxy</h2>
<h2 className="text-lg font-bold">{t("globalProxy")}</h2>
</div>
<p className="text-sm text-text-muted mb-4">
Configure a global outbound proxy for all API calls. Individual providers, combos, and
keys can override this.
</p>
<p className="text-sm text-text-muted mb-4">{t("globalProxyDesc")}</p>
<div className="flex items-center gap-3">
{globalProxy ? (
<div className="flex items-center gap-2">
@@ -58,7 +58,7 @@ export default function ProxyTab() {
</span>
</div>
) : (
<span className="text-sm text-text-muted">No global proxy configured</span>
<span className="text-sm text-text-muted">{t("noGlobalProxy")}</span>
)}
<Button
size="sm"
@@ -69,7 +69,7 @@ export default function ProxyTab() {
setProxyModalOpen(true);
}}
>
{globalProxy ? "Edit" : "Configure"}
{globalProxy ? tc("edit") : t("configure")}
</Button>
</div>
</div>
@@ -79,7 +79,7 @@ export default function ProxyTab() {
isOpen={proxyModalOpen}
onClose={() => setProxyModalOpen(false)}
level="global"
levelLabel="Global"
levelLabel={t("globalLabel")}
onSaved={loadGlobalProxy}
/>
</>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useLocale, useTranslations } from "next-intl";
// ─── State colors and labels ──────────────────────────────────────────────
const STATE_STYLES = {
@@ -10,31 +11,37 @@ const STATE_STYLES = {
bg: "bg-emerald-500/15",
text: "text-emerald-400",
border: "border-emerald-500/30",
label: "CLOSED",
icon: "check_circle",
},
OPEN: {
bg: "bg-red-500/15",
text: "text-red-400",
border: "border-red-500/30",
label: "OPEN",
icon: "error",
},
HALF_OPEN: {
bg: "bg-amber-500/15",
text: "text-amber-400",
border: "border-amber-500/30",
label: "HALF-OPEN",
icon: "warning",
},
};
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
closed: { icon: "check_circle", color: "#22c55e" },
"half-open": { icon: "pending", color: "#f59e0b" },
open: { icon: "error", color: "#ef4444" },
};
function getBreakerStateLabel(state, t) {
const normalized = String(state || "closed")
.toLowerCase()
.replaceAll("_", "-");
if (normalized === "open") return t("breakerStateOpen");
if (normalized === "half-open") return t("breakerStateHalfOpen");
return t("breakerStateClosed");
}
function formatMs(ms) {
if (!ms || ms <= 0) return "—";
if (ms < 1000) return `${ms}ms`;
@@ -42,21 +49,32 @@ function formatMs(ms) {
return `${(ms / 60000).toFixed(1)}m`;
}
function getErrorMessage(err, fallback) {
return err instanceof Error && err.message ? err.message : fallback;
}
// ─── Provider Profiles Card ──────────────────────────────────────────────
function ProviderProfilesCard({ profiles, onSave, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(profiles);
const t = useTranslations("settings");
const tc = useTranslations("common");
useEffect(() => {
setDraft(profiles);
}, [profiles]);
const formatMsRaw = (value) => (value == null ? "—" : `${value}${t("ms")}`);
const fields = [
{ key: "transientCooldown", label: "Transient Cooldown", suffix: "ms" },
{ key: "rateLimitCooldown", label: "Rate Limit Cooldown", suffix: "ms" },
{ key: "maxBackoffLevel", label: "Max Backoff Level", suffix: "" },
{ key: "circuitBreakerThreshold", label: "CB Threshold", suffix: " fails" },
{ key: "circuitBreakerReset", label: "CB Reset Time", suffix: "ms" },
{ key: "transientCooldown", label: t("transientCooldown"), format: formatMsRaw },
{ key: "rateLimitCooldown", label: t("rateLimitCooldown"), format: formatMsRaw },
{ key: "maxBackoffLevel", label: t("maxBackoffLevel") },
{
key: "circuitBreakerThreshold",
label: t("cbThreshold"),
format: (value) => (value == null ? "—" : t("failures", { count: value })),
},
{ key: "circuitBreakerReset", label: t("cbResetTime"), format: formatMsRaw },
];
const handleSave = () => {
@@ -72,12 +90,12 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
tune
</span>
<h2 className="text-lg font-bold">Provider Profiles</h2>
<h2 className="text-lg font-bold">{t("providerProfiles")}</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
{tc("cancel")}
</Button>
<Button
size="sm"
@@ -86,20 +104,17 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
onClick={handleSave}
disabled={saving}
>
Save
{tc("save")}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
{tc("edit")}
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
Separate resilience settings for OAuth (session-based) and API Key (metered) providers.
OAuth providers have stricter thresholds due to lower rate limits.
</p>
<p className="text-sm text-text-muted mb-4">{t("providerProfilesDesc")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{["oauth", "apikey"].map((type) => (
@@ -108,10 +123,10 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
<span className="material-symbols-outlined text-base" aria-hidden="true">
{type === "oauth" ? "lock" : "key"}
</span>
{type === "oauth" ? "OAuth Providers" : "API Key Providers"}
{type === "oauth" ? t("oauthProviders") : t("apiKeyProviders")}
</h3>
<div className="space-y-2">
{fields.map(({ key, label, suffix }) => (
{fields.map(({ key, label, format }) => (
<div key={key} className="flex items-center justify-between">
<span className="text-xs text-text-muted">{label}</span>
{editMode ? (
@@ -129,8 +144,9 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
/>
) : (
<span className="text-sm font-mono">
{profiles?.[type]?.[key] ?? "—"}
{suffix && profiles?.[type]?.[key] != null ? suffix : ""}
{format
? format(profiles?.[type]?.[key])
: (profiles?.[type]?.[key] ?? "—")}
</span>
)}
</div>
@@ -148,6 +164,8 @@ function ProviderProfilesCard({ profiles, onSave, saving }) {
function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
const [editMode, setEditMode] = useState(false);
const [draft, setDraft] = useState(defaults || {});
const t = useTranslations("settings");
const tc = useTranslations("common");
// Sync draft when defaults change from parent (standard prop-to-state sync)
/* eslint-disable react-hooks/set-state-in-effect */
@@ -169,12 +187,12 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
speed
</span>
<h2 className="text-lg font-bold">Rate Limiting</h2>
<h2 className="text-lg font-bold">{t("rateLimiting")}</h2>
</div>
{editMode ? (
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditMode(false)}>
Cancel
{tc("cancel")}
</Button>
<Button
size="sm"
@@ -183,30 +201,27 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
onClick={handleSave}
disabled={saving}
>
Save
{tc("save")}
</Button>
</div>
) : (
<Button size="sm" variant="secondary" icon="edit" onClick={() => setEditMode(true)}>
Edit
{tc("edit")}
</Button>
)}
</div>
<p className="text-sm text-text-muted mb-4">
API Key providers are automatically rate-limited with safe defaults. Limits are learned
from response headers and adapt over time.
</p>
<p className="text-sm text-text-muted mb-4">{t("rateLimitingDesc")}</p>
<div className="rounded-lg bg-black/5 dark:bg-white/5 p-4 mb-4">
<h3 className="text-xs font-bold uppercase tracking-wider mb-3 text-text-muted">
Default Safety Net
{t("defaultSafetyNet")}
</h3>
<div className="grid grid-cols-3 gap-4">
{[
{ key: "requestsPerMinute", label: "RPM", suffix: "" },
{ key: "minTimeBetweenRequests", label: "Min Gap", suffix: "ms", format: formatMs },
{ key: "concurrentRequests", label: "Max Concurrent", suffix: "" },
{ key: "requestsPerMinute", label: t("rpm") },
{ key: "minTimeBetweenRequests", label: t("minGap"), format: formatMs },
{ key: "concurrentRequests", label: t("maxConcurrent") },
].map(({ key, label, format }) => (
<div key={key}>
{editMode ? (
@@ -233,7 +248,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
{rateLimitStatus && rateLimitStatus.length > 0 ? (
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-text-muted">
Active Limiters
{t("activeLimiters")}
</h3>
{rateLimitStatus.map((rl, i) => (
<div
@@ -242,15 +257,27 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
>
<span className="text-sm font-medium">{rl.provider || rl.key}</span>
<div className="flex items-center gap-3 text-xs text-text-muted">
{rl.reservoir != null && <span>Reservoir: {rl.reservoir}</span>}
{rl.running != null && <span>Running: {rl.running}</span>}
{rl.queued != null && <span>Queued: {rl.queued}</span>}
{rl.reservoir != null && (
<span>
{t("reservoir")}: {rl.reservoir}
</span>
)}
{rl.running != null && (
<span>
{t("running")}: {rl.running}
</span>
)}
{rl.queued != null && (
<span>
{t("queued")}: {rl.queued}
</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-text-muted">No active rate limiters yet.</p>
<p className="text-xs text-text-muted">{t("noActiveLimiters")}</p>
)}
</div>
</Card>
@@ -261,6 +288,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
function CircuitBreakerCard({ breakers, onReset, loading }) {
const activeBreakers = breakers.filter((b) => b.state !== "CLOSED");
const totalBreakers = breakers.length;
const t = useTranslations("settings");
return (
<Card className="p-0 overflow-hidden">
@@ -270,13 +298,13 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
electrical_services
</span>
<h2 className="text-lg font-bold">Circuit Breakers</h2>
<h2 className="text-lg font-bold">{t("circuitBreakers")}</h2>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">
{activeBreakers.length > 0
? `${activeBreakers.length} tripped`
: `${totalBreakers} healthy`}
? t("tripped", { count: activeBreakers.length })
: t("healthy", { count: totalBreakers })}
</span>
{activeBreakers.length > 0 && (
<Button
@@ -286,17 +314,14 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
onClick={onReset}
disabled={loading}
>
Reset All
{t("resetAll")}
</Button>
)}
</div>
</div>
{breakers.length === 0 ? (
<p className="text-sm text-text-muted">
No circuit breakers active yet. They are created automatically when requests flow
through the combo pipeline.
</p>
<p className="text-sm text-text-muted">{t("noCircuitBreakers")}</p>
) : (
<div className="space-y-2">
{breakers.map((b) => {
@@ -318,13 +343,13 @@ function CircuitBreakerCard({ breakers, onReset, loading }) {
<div className="flex items-center gap-3">
{b.failureCount > 0 && (
<span className="text-xs text-text-muted">
{b.failureCount} failure{b.failureCount !== 1 ? "s" : ""}
{t("failures", { count: b.failureCount })}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs font-bold uppercase ${style.bg} ${style.text} border ${style.border}`}
>
{style.label}
{getBreakerStateLabel(b.state, t)}
</span>
</div>
</div>
@@ -343,6 +368,8 @@ function PoliciesCard() {
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const locale = useLocale();
const t = useTranslations("settings");
const fetchPolicies = useCallback(async () => {
try {
@@ -373,13 +400,13 @@ function PoliciesCard() {
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
notify.success(t("unlockedIdentifier", { identifier }));
await fetchPolicies();
} else {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
}
} catch {
notify.error("Failed to unlock");
notify.error(t("failedUnlock"));
} finally {
setUnlocking(null);
}
@@ -394,7 +421,7 @@ function PoliciesCard() {
<Card className="p-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">policy</span>
Loading policies...
{t("loadingPolicies")}
</div>
</Card>
);
@@ -408,7 +435,7 @@ function PoliciesCard() {
<span className="material-symbols-outlined text-xl text-primary" aria-hidden="true">
policy
</span>
<h2 className="text-lg font-bold">Policies & Locked Identifiers</h2>
<h2 className="text-lg font-bold">{t("policiesLocked")}</h2>
</div>
{hasIssues && (
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
@@ -423,9 +450,7 @@ function PoliciesCard() {
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
<p className="text-sm text-text-muted">{t("allOperational")}</p>
</div>
</div>
) : (
@@ -433,7 +458,7 @@ function PoliciesCard() {
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("circuitBreakers")}</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
@@ -452,7 +477,7 @@ function PoliciesCard() {
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
{cb.name || cb.provider || t("unknown")}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
@@ -461,11 +486,11 @@ function PoliciesCard() {
color: status.color,
}}
>
{status.label}
{getBreakerStateLabel(cb.state, t)}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">
{cb.failures} failures
{t("failures", { count: cb.failures })}
</span>
)}
</div>
@@ -479,7 +504,7 @@ function PoliciesCard() {
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<p className="text-sm font-medium text-text-muted mb-2">{t("lockedIdentifiers")}</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
@@ -495,7 +520,9 @@ function PoliciesCard() {
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
{t("sinceDate", {
date: new Date(id.lockedAt).toLocaleString(locale),
})}
</span>
)}
</div>
@@ -506,7 +533,7 @@ function PoliciesCard() {
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
</Button>
</div>
);
@@ -527,21 +554,22 @@ export default function ResilienceTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const t = useTranslations("settings");
const loadData = useCallback(async () => {
try {
setLoading(true);
const res = await fetch("/api/resilience");
if (!res.ok) throw new Error(`Failed to load: ${res.status}`);
if (!res.ok) throw new Error(t("failedLoadWithStatus", { status: res.status }));
const json = await res.json();
setData(json);
setError(null);
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("failedLoadResilience")));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
loadData();
@@ -554,10 +582,10 @@ export default function ResilienceTab() {
try {
setLoading(true);
const res = await fetch("/api/resilience/reset", { method: "POST" });
if (!res.ok) throw new Error("Reset failed");
if (!res.ok) throw new Error(t("resetFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("resetFailed")));
} finally {
setLoading(false);
}
@@ -571,10 +599,10 @@ export default function ResilienceTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ profiles }),
});
if (!res.ok) throw new Error("Save failed");
if (!res.ok) throw new Error(t("saveFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("saveFailed")));
} finally {
setSaving(false);
}
@@ -588,10 +616,10 @@ export default function ResilienceTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaults }),
});
if (!res.ok) throw new Error("Save failed");
if (!res.ok) throw new Error(t("saveFailed"));
await loadData();
} catch (err) {
setError(err.message);
setError(getErrorMessage(err, t("saveFailed")));
} finally {
setSaving(false);
}
@@ -601,7 +629,7 @@ export default function ResilienceTab() {
return (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">hourglass_empty</span>
Loading resilience status...
{t("loadingResilience")}
</div>
);
}
@@ -614,7 +642,7 @@ export default function ResilienceTab() {
<span className="text-sm">{error}</span>
</div>
<Button size="sm" variant="secondary" icon="refresh" onClick={loadData} className="mt-3">
Retry
{t("retry")}
</Button>
</Card>
);

View File

@@ -1,29 +1,30 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Input, Toggle, Button } from "@/shared/components";
import { Card, Input, Button } from "@/shared/components";
import FallbackChainsEditor from "./FallbackChainsEditor";
import { useTranslations } from "next-intl";
const STRATEGIES = [
{
value: "fill-first",
label: "Fill First",
desc: "Use accounts in priority order",
labelKey: "fillFirst",
descKey: "fillFirstDesc",
icon: "vertical_align_top",
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
{ value: "random", label: "Random", desc: "Random account each request", icon: "shuffle" },
{ value: "round-robin", labelKey: "roundRobin", descKey: "roundRobinDesc", icon: "loop" },
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "balance" },
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
{
value: "least-used",
label: "Least Used",
desc: "Pick least recently used account",
labelKey: "leastUsed",
descKey: "leastUsedDesc",
icon: "low_priority",
},
{
value: "cost-optimized",
label: "Cost Opt",
desc: "Prefer cheapest available account",
labelKey: "costOpt",
descKey: "costOptDesc",
icon: "savings",
},
];
@@ -34,6 +35,15 @@ export default function RoutingTab() {
const [aliases, setAliases] = useState([]);
const [newPattern, setNewPattern] = useState("");
const [newTarget, setNewTarget] = useState("");
const t = useTranslations("settings");
const strategyHintKeyByValue: Record<string, string> = {
"fill-first": "fillFirstDesc",
"round-robin": "roundRobinDesc",
p2c: "p2cDesc",
random: "randomDesc",
"least-used": "leastUsedDesc",
"cost-optimized": "costOptDesc",
};
useEffect(() => {
fetch("/api/settings")
@@ -86,7 +96,7 @@ export default function RoutingTab() {
route
</span>
</div>
<h3 className="text-lg font-semibold">Routing Strategy</h3>
<h3 className="text-lg font-semibold">{t("routingStrategy")}</h3>
</div>
<div className="grid grid-cols-3 gap-2 mb-4" style={{ gridAutoRows: "1fr" }}>
@@ -112,9 +122,9 @@ export default function RoutingTab() {
<p
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
>
{s.label}
{t(s.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5">{s.desc}</p>
<p className="text-xs text-text-muted mt-0.5">{t(s.descKey)}</p>
</div>
</button>
))}
@@ -123,8 +133,8 @@ export default function RoutingTab() {
{settings.fallbackStrategy === "round-robin" && (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div>
<p className="text-sm font-medium">Sticky Limit</p>
<p className="text-xs text-text-muted">Calls per account before switching</p>
<p className="text-sm font-medium">{t("stickyLimit")}</p>
<p className="text-xs text-text-muted">{t("stickyLimitDesc")}</p>
</div>
<Input
type="number"
@@ -139,18 +149,7 @@ export default function RoutingTab() {
)}
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
{settings.fallbackStrategy === "round-robin" &&
`Distributing requests across accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`}
{settings.fallbackStrategy === "fill-first" &&
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
{settings.fallbackStrategy === "random" &&
"Randomly selects an available account for each request."}
{settings.fallbackStrategy === "least-used" &&
"Picks the account that was used least recently."}
{settings.fallbackStrategy === "cost-optimized" &&
"Prefers accounts with the lowest cost (priority-based, extensible with actual cost data)."}
{t(strategyHintKeyByValue[settings.fallbackStrategy] || "fillFirstDesc")}
</p>
</Card>
@@ -163,10 +162,8 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Model Aliases</h3>
<p className="text-sm text-text-muted">
Wildcard patterns to remap model names Use * and ?
</p>
<h3 className="text-lg font-semibold">{t("modelAliases")}</h3>
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
</div>
</div>
@@ -198,22 +195,22 @@ export default function RoutingTab() {
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Pattern"
placeholder="claude-sonnet-*"
label={t("pattern")}
placeholder={t("aliasPatternPlaceholder")}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
/>
</div>
<div className="flex-1">
<Input
label="Target Model"
placeholder="claude-sonnet-4-20250514"
label={t("targetModel")}
placeholder={t("aliasTargetPlaceholder")}
value={newTarget}
onChange={(e) => setNewTarget(e.target.value)}
/>
</div>
<Button size="sm" variant="primary" onClick={addAlias} className="mb-[2px]">
+ Add
{t("add")}
</Button>
</div>
</Card>

View File

@@ -5,6 +5,7 @@ import { Card, Button, Input, Toggle } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import IPFilterSection from "./IPFilterSection";
import SessionInfoCard from "./SessionInfoCard";
import { useTranslations } from "next-intl";
export default function SecurityTab() {
const [settings, setSettings] = useState<any>({ requireLogin: false, hasPassword: false });
@@ -12,6 +13,7 @@ export default function SecurityTab() {
const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" });
const [passStatus, setPassStatus] = useState({ type: "", message: "" });
const [passLoading, setPassLoading] = useState(false);
const t = useTranslations("settings");
useEffect(() => {
fetch("/api/settings")
@@ -64,7 +66,7 @@ export default function SecurityTab() {
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
setPassStatus({ type: "error", message: "Passwords do not match" });
setPassStatus({ type: "error", message: t("passwordsNoMatch") });
return;
}
@@ -82,13 +84,13 @@ export default function SecurityTab() {
});
const data = await res.json();
if (res.ok) {
setPassStatus({ type: "success", message: "Password updated successfully" });
setPassStatus({ type: "success", message: t("passwordUpdated") });
setPasswords({ current: "", new: "", confirm: "" });
} else {
setPassStatus({ type: "error", message: data.error || "Failed to update password" });
setPassStatus({ type: "error", message: data.error || t("failedUpdatePassword") });
}
} catch {
setPassStatus({ type: "error", message: "An error occurred" });
setPassStatus({ type: "error", message: t("errorOccurred") });
} finally {
setPassLoading(false);
}
@@ -105,15 +107,13 @@ export default function SecurityTab() {
shield
</span>
</div>
<h3 className="text-lg font-semibold">Security</h3>
<h3 className="text-lg font-semibold">{t("security")}</h3>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require login</p>
<p className="text-sm text-text-muted">
When ON, dashboard requires password. When OFF, access without login.
</p>
<p className="font-medium">{t("requireLogin")}</p>
<p className="text-sm text-text-muted">{t("requireLoginDesc")}</p>
</div>
<Toggle
checked={settings.requireLogin === true}
@@ -128,9 +128,9 @@ export default function SecurityTab() {
>
{settings.hasPassword && (
<Input
label="Current Password"
label={t("currentPassword")}
type="password"
placeholder="Enter current password"
placeholder={t("enterCurrentPassword")}
value={passwords.current}
onChange={(e) => setPasswords({ ...passwords, current: e.target.value })}
required
@@ -138,17 +138,17 @@ export default function SecurityTab() {
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="New Password"
label={t("newPassword")}
type="password"
placeholder="Enter new password"
placeholder={t("enterNewPassword")}
value={passwords.new}
onChange={(e) => setPasswords({ ...passwords, new: e.target.value })}
required
/>
<Input
label="Confirm New Password"
label={t("confirmPassword")}
type="password"
placeholder="Confirm new password"
placeholder={t("confirmPasswordPlaceholder")}
value={passwords.confirm}
onChange={(e) => setPasswords({ ...passwords, confirm: e.target.value })}
required
@@ -165,7 +165,7 @@ export default function SecurityTab() {
<div className="pt-2">
<Button type="submit" variant="primary" loading={passLoading}>
{settings.hasPassword ? "Update Password" : "Set Password"}
{settings.hasPassword ? t("updatePassword") : t("setPassword")}
</Button>
</div>
</form>
@@ -181,21 +181,14 @@ export default function SecurityTab() {
api
</span>
</div>
<h3 className="text-lg font-semibold">API Endpoint Protection</h3>
<h3 className="text-lg font-semibold">{t("apiEndpointProtection")}</h3>
</div>
<div className="flex flex-col gap-4">
{/* Require auth for /models */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Require API key for /models</p>
<p className="text-sm text-text-muted">
When ON, the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
endpoint returns 404 for unauthenticated requests. Prevents model discovery by
unauthorized users.
</p>
<p className="font-medium">{t("requireAuthModels")}</p>
<p className="text-sm text-text-muted">{t("requireAuthModelsDesc")}</p>
</div>
<Toggle
checked={settings.requireAuthForModels === true}
@@ -207,14 +200,8 @@ export default function SecurityTab() {
{/* Blocked Providers */}
<div className="pt-4 border-t border-border/50">
<div className="mb-3">
<p className="font-medium">Blocked Providers</p>
<p className="text-sm text-text-muted">
Hide specific providers from the{" "}
<code className="text-xs bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded">
/v1/models
</code>{" "}
response. Blocked providers will not appear in model listings.
</p>
<p className="font-medium">{t("blockedProviders")}</p>
<p className="text-sm text-text-muted">{t("blockedProvidersDesc")}</p>
</div>
<div className="flex flex-wrap gap-2">
{Object.values(AI_PROVIDERS).map((provider: any) => {
@@ -229,7 +216,11 @@ export default function SecurityTab() {
? "bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
: "bg-black/[0.02] dark:bg-white/[0.02] border-transparent text-text-muted hover:bg-black/[0.05] dark:hover:bg-white/[0.05]"
}`}
title={isBlocked ? `Unblock ${provider.name}` : `Block ${provider.name}`}
title={
isBlocked
? t("unblockProviderTitle", { provider: provider.name })
: t("blockProviderTitle", { provider: provider.name })
}
>
<span
className="material-symbols-outlined text-[14px]"
@@ -250,8 +241,7 @@ export default function SecurityTab() {
{blockedProviders.length > 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">warning</span>
{blockedProviders.length} provider{blockedProviders.length !== 1 ? "s" : ""} blocked
from /models
{t("providersBlocked", { count: blockedProviders.length })}
</p>
)}
</div>

View File

@@ -9,6 +9,7 @@
import { useState, useEffect } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
interface SessionInfo {
authenticated: boolean;
@@ -21,6 +22,7 @@ interface SessionInfo {
export default function SessionInfoCard() {
const [session, setSession] = useState<SessionInfo | null>(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("settings");
useEffect(() => {
let cancelled = false;
@@ -30,7 +32,7 @@ export default function SessionInfoCard() {
const loginTime = sessionStorage.getItem("omniroute_login_time");
const now = Date.now();
let sessionAge = "Unknown";
let sessionAge = t("unknown");
if (loginTime) {
const elapsed = now - parseInt(loginTime, 10);
const hours = Math.floor(elapsed / 3600000);
@@ -59,7 +61,7 @@ export default function SessionInfoCard() {
loginTime: loginTime ? new Date(parseInt(loginTime, 10)).toLocaleString() : null,
sessionAge,
ipAddress: "—", // Server-side only
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || "Unknown",
userAgent: navigator.userAgent.split(" ").slice(-2).join(" ") || t("unknown"),
});
setLoading(false);
}
@@ -81,7 +83,7 @@ export default function SessionInfoCard() {
};
const handleClearStorage = () => {
if (confirm("Clear all local data? This will reset your preferences.")) {
if (confirm(t("clearLocalDataConfirm"))) {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
@@ -104,46 +106,46 @@ export default function SessionInfoCard() {
person
</span>
</div>
<h3 className="text-lg font-semibold">Session</h3>
<h3 className="text-lg font-semibold">{t("session")}</h3>
</div>
<div className="flex flex-col gap-3" role="list" aria-label="Session details">
<div className="flex flex-col gap-3" role="list" aria-label={t("sessionDetailsAria")}>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Status</span>
<span className="text-text-muted">{t("status")}</span>
<span className="flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${session?.authenticated ? "bg-green-500" : "bg-yellow-500"}`}
aria-hidden="true"
/>
{session?.authenticated ? "Authenticated" : "Guest"}
{session?.authenticated ? t("authenticated") : t("guest")}
</span>
</div>
{session?.loginTime && (
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Login Time</span>
<span className="text-text-muted">{t("loginTime")}</span>
<span className="font-mono text-xs">{session.loginTime}</span>
</div>
)}
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Session Age</span>
<span className="text-text-muted">{t("sessionAge")}</span>
<span className="font-mono text-xs">{session?.sessionAge}</span>
</div>
<div className="flex justify-between items-center text-sm" role="listitem">
<span className="text-text-muted">Browser</span>
<span className="text-text-muted">{t("browser")}</span>
<span className="font-mono text-xs truncate max-w-[200px]">{session?.userAgent}</span>
</div>
</div>
<div className="flex gap-3 mt-4 pt-4 border-t border-border/50">
<Button variant="secondary" onClick={handleClearStorage}>
Clear Local Data
{t("clearLocalData")}
</Button>
{session?.authenticated && (
<Button variant="danger" onClick={handleLogout}>
Logout
{t("logout")}
</Button>
)}
</div>

View File

@@ -2,12 +2,14 @@
import { useState, useEffect } from "react";
import { Card, Toggle } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function SystemPromptTab() {
const [config, setConfig] = useState({ enabled: false, prompt: "" });
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState("");
const [debounceTimer, setDebounceTimer] = useState(null);
const t = useTranslations("settings");
useEffect(() => {
fetch("/api/settings/system-prompt")
@@ -57,13 +59,14 @@ export default function SystemPromptTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Global System Prompt</h3>
<p className="text-sm text-text-muted">Injected into all requests at proxy level</p>
<h3 className="text-lg font-semibold">{t("globalSystemPrompt")}</h3>
<p className="text-sm text-text-muted">{t("systemPromptDesc")}</p>
</div>
<div className="flex items-center gap-3">
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span> Saved
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved")}
</span>
)}
<Toggle
@@ -80,7 +83,7 @@ export default function SystemPromptTab() {
<textarea
value={config.prompt}
onChange={(e) => handlePromptChange(e.target.value)}
placeholder="Enter system prompt to inject into all requests..."
placeholder={t("systemPromptPlaceholder")}
rows={5}
className="w-full px-4 py-3 rounded-lg border border-border/50 bg-surface/30 text-sm
placeholder:text-text-muted/50 resize-y min-h-[120px]
@@ -94,8 +97,9 @@ export default function SystemPromptTab() {
</div>
<p className="text-xs text-text-muted/70 flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">info</span>
This prompt is prepended to the system message of every request. Use for global instructions,
safety guidelines, or response formatting rules. Send <code className="px-1 py-0.5 rounded bg-surface/50">_skipSystemPrompt: true</code> in a request to bypass.
{t("systemPromptHint")} Send{" "}
<code className="px-1 py-0.5 rounded bg-surface/50">_skipSystemPrompt: true</code> in a
request to bypass.
</p>
</div>
)}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Badge } from "@/shared/components";
import { useLocale, useTranslations } from "next-intl";
export default function SystemStorageTab() {
const [backups, setBackups] = useState([]);
@@ -18,6 +19,9 @@ export default function SystemStorageTab() {
const [confirmImport, setConfirmImport] = useState(false);
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const locale = useLocale();
const t = useTranslations("settings");
const tc = useTranslations("common");
const [storageHealth, setStorageHealth] = useState({
driver: "sqlite",
dbPath: "~/.omniroute/storage.sqlite",
@@ -58,20 +62,23 @@ export default function SystemStorageTab() {
const data = await res.json();
if (res.ok) {
if (data.filename) {
setManualBackupStatus({ type: "success", message: `Backup created: ${data.filename}` });
setManualBackupStatus({
type: "success",
message: t("backupCreated", { file: data.filename }),
});
} else {
setManualBackupStatus({
type: "info",
message: data.message || "No changes since last backup",
message: data.message || t("noChangesSinceBackup"),
});
}
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setManualBackupStatus({ type: "error", message: data.error || "Backup failed" });
setManualBackupStatus({ type: "error", message: data.error || t("backupFailed") });
}
} catch {
setManualBackupStatus({ type: "error", message: "An error occurred" });
setManualBackupStatus({ type: "error", message: t("errorOccurred") });
} finally {
setManualBackupLoading(false);
}
@@ -90,15 +97,20 @@ export default function SystemStorageTab() {
if (res.ok) {
setRestoreStatus({
type: "success",
message: `Restored! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
message: t("restoreSuccess", {
connections: data.connectionCount,
nodes: data.nodeCount,
combos: data.comboCount,
apiKeys: data.apiKeyCount,
}),
});
await loadBackups();
await loadStorageHealth();
} else {
setRestoreStatus({ type: "error", message: data.error || "Restore failed" });
setRestoreStatus({ type: "error", message: data.error || t("restoreFailed") });
}
} catch {
setRestoreStatus({ type: "error", message: "An error occurred during restore" });
setRestoreStatus({ type: "error", message: t("errorDuringRestore") });
} finally {
setRestoringId(null);
setConfirmRestoreId(null);
@@ -115,7 +127,7 @@ export default function SystemStorageTab() {
const res = await fetch("/api/db-backups/export");
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Export failed");
throw new Error(data.error || t("exportFailed"));
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
@@ -133,7 +145,10 @@ export default function SystemStorageTab() {
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed:", err);
setImportStatus({ type: "error", message: `Export failed: ${(err as Error).message}` });
setImportStatus({
type: "error",
message: t("exportFailedWithError", { error: (err as Error).message }),
});
} finally {
setExportLoading(false);
}
@@ -149,7 +164,7 @@ export default function SystemStorageTab() {
if (!file.name.endsWith(".sqlite")) {
setImportStatus({
type: "error",
message: "Invalid file type. Only .sqlite files are accepted.",
message: t("invalidFileType"),
});
return;
}
@@ -174,15 +189,20 @@ export default function SystemStorageTab() {
if (res.ok) {
setImportStatus({
type: "success",
message: `Database imported! ${data.connectionCount} connections, ${data.nodeCount} nodes, ${data.comboCount} combos, ${data.apiKeyCount} API keys.`,
message: t("importSuccess", {
connections: data.connectionCount,
nodes: data.nodeCount,
combos: data.comboCount,
apiKeys: data.apiKeyCount,
}),
});
await loadStorageHealth();
if (backupsExpanded) await loadBackups();
} else {
setImportStatus({ type: "error", message: data.error || "Import failed" });
setImportStatus({ type: "error", message: data.error || t("importFailed") });
}
} catch {
setImportStatus({ type: "error", message: "An error occurred during import" });
setImportStatus({ type: "error", message: t("errorDuringImport") });
} finally {
setImportLoading(false);
setPendingImportFile(null);
@@ -207,12 +227,18 @@ export default function SystemStorageTab() {
const then = new Date(isoString);
const diffMs = (now as any) - (then as any);
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1) return t("justNow");
if (diffMin < 60) return t("minutesAgo", { count: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
if (diffHr < 24) return t("hoursAgo", { count: diffHr });
const diffDays = Math.floor(diffHr / 24);
return `${diffDays}d ago`;
return t("daysAgo", { count: diffDays });
};
const formatBackupReason = (reason) => {
if (reason === "manual") return t("backupReasonManual");
if (reason === "pre-restore") return t("backupReasonPreRestore");
return reason;
};
return (
@@ -224,8 +250,8 @@ export default function SystemStorageTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">System & Storage</h3>
<p className="text-xs text-text-muted">All data stored locally on your machine</p>
<h3 className="text-lg font-semibold">{t("systemStorage")}</h3>
<p className="text-xs text-text-muted">{t("allDataLocal")}</p>
</div>
<Badge variant="success" size="sm">
{storageHealth.driver || "json"}
@@ -235,13 +261,17 @@ export default function SystemStorageTab() {
{/* Storage info grid */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-bg border border-border">
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">Database Path</p>
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">
{t("databasePath")}
</p>
<p className="text-sm font-mono text-text-main break-all">
{storageHealth.dbPath || "~/.omniroute/storage.sqlite"}
</p>
</div>
<div className="p-3 rounded-lg bg-bg border border-border">
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">Database Size</p>
<p className="text-[11px] text-text-muted uppercase tracking-wide mb-1">
{t("databaseSize")}
</p>
<p className="text-sm font-mono text-text-main">{formatBytes(storageHealth.sizeBytes)}</p>
</div>
</div>
@@ -252,7 +282,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
download
</span>
Export Database
{t("exportDatabase")}
</Button>
<Button
variant="outline"
@@ -261,7 +291,7 @@ export default function SystemStorageTab() {
setExportLoading(true);
try {
const res = await fetch("/api/db-backups/exportAll");
if (!res.ok) throw new Error("Export failed");
if (!res.ok) throw new Error(t("exportFailed"));
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const filenameMatch = cd.match(/filename="?([^"]+)"?/);
@@ -277,7 +307,7 @@ export default function SystemStorageTab() {
} catch (err) {
setImportStatus({
type: "error",
message: `Full export failed: ${(err as Error).message}`,
message: t("fullExportFailedWithError", { error: (err as Error).message }),
});
} finally {
setExportLoading(false);
@@ -288,13 +318,13 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
folder_zip
</span>
Export All (.tar.gz)
{t("exportAll")}
</Button>
<Button variant="outline" size="sm" onClick={handleImportClick} loading={importLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
upload
</span>
Import Database
{t("importDatabase")}
</Button>
<input
ref={fileInputRef}
@@ -316,11 +346,9 @@ export default function SystemStorageTab() {
warning
</span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-500 mb-1">Confirm Database Import</p>
<p className="text-sm font-medium text-amber-500 mb-1">{t("confirmDbImport")}</p>
<p className="text-xs text-text-muted mb-2">
This will replace <strong>all current data</strong> with the content from{" "}
<span className="font-mono">{pendingImportFile.name}</span>. A backup will be
created automatically before the import.
{t("confirmDbImportDesc", { file: pendingImportFile.name })}
</p>
<div className="flex items-center gap-2">
<Button
@@ -329,10 +357,10 @@ export default function SystemStorageTab() {
onClick={handleImportConfirm}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes, Import
{t("yesImport")}
</Button>
<Button variant="outline" size="sm" onClick={handleImportCancel}>
Cancel
{tc("cancel")}
</Button>
</div>
</div>
@@ -364,11 +392,11 @@ export default function SystemStorageTab() {
schedule
</span>
<div>
<p className="text-sm font-medium">Last Backup</p>
<p className="text-sm font-medium">{t("lastBackup")}</p>
<p className="text-xs text-text-muted">
{storageHealth.lastBackupAt
? `${new Date(storageHealth.lastBackupAt).toLocaleString("pt-BR")} (${formatRelativeTime(storageHealth.lastBackupAt)})`
: "No backup yet"}
? `${new Date(storageHealth.lastBackupAt).toLocaleString(locale)} (${formatRelativeTime(storageHealth.lastBackupAt)})`
: t("noBackupYet")}
</p>
</div>
</div>
@@ -381,7 +409,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
backup
</span>
Backup Now
{t("backupNow")}
</Button>
</div>
@@ -419,7 +447,7 @@ export default function SystemStorageTab() {
>
restore
</span>
<p className="font-medium">Backup & Restore</p>
<p className="font-medium">{t("backupRestore")}</p>
</div>
<Button
variant="outline"
@@ -429,13 +457,10 @@ export default function SystemStorageTab() {
if (!backupsExpanded && backups.length === 0) loadBackups();
}}
>
{backupsExpanded ? "Hide" : "View Backups"}
{backupsExpanded ? t("hide") : t("viewBackups")}
</Button>
</div>
<p className="text-xs text-text-muted mb-3">
Database snapshots are created automatically before restore and every 15 minutes when data
changes. Retention: 24 hourly + 30 daily backups with smart rotation.
</p>
<p className="text-xs text-text-muted mb-3">{t("backupRetentionDesc")}</p>
{restoreStatus.message && (
<div
@@ -465,7 +490,7 @@ export default function SystemStorageTab() {
>
progress_activity
</span>
Loading backups...
{t("loadingBackups")}
</div>
) : backups.length === 0 ? (
<div className="text-center py-6 text-text-muted text-sm">
@@ -475,13 +500,13 @@ export default function SystemStorageTab() {
>
folder_off
</span>
No backups available yet. Backups will be created automatically when data changes.
{t("noBackupsYet")}
</div>
) : (
<>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">
{backups.length} backup(s) available
{t("backupsAvailable", { count: backups.length })}
</span>
<button
onClick={loadBackups}
@@ -490,7 +515,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh
</span>
Refresh
{t("refresh")}
</button>
</div>
{backups.map((backup) => (
@@ -507,7 +532,7 @@ export default function SystemStorageTab() {
description
</span>
<span className="text-sm font-medium truncate">
{new Date(backup.createdAt).toLocaleString("pt-BR")}
{new Date(backup.createdAt).toLocaleString(locale)}
</span>
<Badge
variant={
@@ -519,11 +544,11 @@ export default function SystemStorageTab() {
}
size="sm"
>
{backup.reason}
{formatBackupReason(backup.reason)}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-text-muted ml-6">
<span>{backup.connectionCount} connection(s)</span>
<span>{t("connectionsCount", { count: backup.connectionCount })}</span>
<span></span>
<span>{formatBytes(backup.size)}</span>
</div>
@@ -531,7 +556,7 @@ export default function SystemStorageTab() {
<div className="flex items-center gap-2 ml-3">
{confirmRestoreId === backup.id ? (
<>
<span className="text-xs text-amber-500 font-medium">Confirm?</span>
<span className="text-xs text-amber-500 font-medium">{t("confirm")}</span>
<Button
variant="primary"
size="sm"
@@ -539,14 +564,14 @@ export default function SystemStorageTab() {
loading={restoringId === backup.id}
className="!bg-amber-500 hover:!bg-amber-600"
>
Yes
{t("yes")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmRestoreId(null)}
>
No
{t("no")}
</Button>
</>
) : (
@@ -561,7 +586,7 @@ export default function SystemStorageTab() {
>
restore
</span>
Restore
{t("restore")}
</Button>
)}
</div>

View File

@@ -1,40 +1,41 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Select } from "@/shared/components";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
const MODES = [
{
value: "passthrough",
label: "Passthrough",
desc: "No changes — client controls thinking budget",
labelKey: "passthrough",
descKey: "passthroughDesc",
icon: "arrow_forward",
},
{
value: "auto",
label: "Auto",
desc: "Strip all thinking config — let provider decide",
labelKey: "auto",
descKey: "autoDesc",
icon: "auto_awesome",
},
{
value: "custom",
label: "Custom",
desc: "Set a fixed token budget for all requests",
labelKey: "custom",
descKey: "customDesc",
icon: "tune",
},
{
value: "adaptive",
label: "Adaptive",
desc: "Scale budget based on request complexity",
labelKey: "adaptive",
descKey: "adaptiveDesc",
icon: "trending_up",
},
];
const EFFORTS = [
{ value: "none", label: "None (0 tokens)" },
{ value: "low", label: "Low (1K tokens)" },
{ value: "medium", label: "Medium (10K tokens)" },
{ value: "high", label: "High (128K tokens)" },
{ value: "none", labelKey: "effortNone" },
{ value: "low", labelKey: "effortLow" },
{ value: "medium", labelKey: "effortMedium" },
{ value: "high", labelKey: "effortHigh" },
];
export default function ThinkingBudgetTab() {
@@ -46,6 +47,7 @@ export default function ThinkingBudgetTab() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const t = useTranslations("settings");
useEffect(() => {
fetch("/api/settings/thinking-budget")
@@ -90,12 +92,12 @@ export default function ThinkingBudgetTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Thinking Budget</h3>
<p className="text-sm text-text-muted">Control AI reasoning token usage across all requests</p>
<h3 className="text-lg font-semibold">{t("thinkingBudgetTitle")}</h3>
<p className="text-sm text-text-muted">{t("thinkingBudgetDesc")}</p>
</div>
{status === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span> Saved
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
</span>
)}
</div>
@@ -121,10 +123,12 @@ export default function ThinkingBudgetTab() {
{m.icon}
</span>
<div className="min-w-0">
<p className={`text-sm font-medium ${config.mode === m.value ? "text-violet-400" : ""}`}>
{m.label}
<p
className={`text-sm font-medium ${config.mode === m.value ? "text-violet-400" : ""}`}
>
{t(m.labelKey)}
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{m.desc}</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{t(m.descKey)}</p>
</div>
</button>
))}
@@ -134,9 +138,9 @@ export default function ThinkingBudgetTab() {
{config.mode === "custom" && (
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium">Token Budget</p>
<p className="text-sm font-medium">{t("tokenBudget")}</p>
<span className="text-sm font-mono tabular-nums text-violet-400">
{config.customBudget.toLocaleString()} tokens
{config.customBudget.toLocaleString()} {t("tokens")}
</span>
</div>
<input
@@ -149,7 +153,7 @@ export default function ThinkingBudgetTab() {
className="w-full accent-violet-500"
/>
<div className="flex justify-between text-xs text-text-muted mt-1">
<span>Off</span>
<span>{t("off")}</span>
<span>1K</span>
<span>10K</span>
<span>64K</span>
@@ -161,10 +165,8 @@ export default function ThinkingBudgetTab() {
{/* Adaptive effort level */}
{config.mode === "adaptive" && (
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<p className="text-sm font-medium mb-3">Base Effort Level</p>
<p className="text-xs text-text-muted mb-3">
Adaptive mode scales from this base level based on message count, tool usage, and prompt length.
</p>
<p className="text-sm font-medium mb-3">{t("baseEffortLevel")}</p>
<p className="text-xs text-text-muted mb-3">{t("adaptiveHint")}</p>
<div className="grid grid-cols-4 gap-2">
{EFFORTS.map((e) => (
<button
@@ -177,7 +179,7 @@ export default function ThinkingBudgetTab() {
: "border-border/50 text-text-muted hover:border-border"
}`}
>
{e.value.charAt(0).toUpperCase() + e.value.slice(1)}
{t(e.labelKey)}
</button>
))}
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
import { useTranslations } from "next-intl";
import SystemStorageTab from "./components/SystemStorageTab";
import SecurityTab from "./components/SecurityTab";
import RoutingTab from "./components/RoutingTab";
@@ -17,15 +18,16 @@ import CacheStatsCard from "./components/CacheStatsCard";
import ResilienceTab from "./components/ResilienceTab";
const tabs = [
{ id: "general", label: "General", icon: "settings" },
{ id: "ai", label: "AI", icon: "smart_toy" },
{ id: "security", label: "Security", icon: "shield" },
{ id: "routing", label: "Routing", icon: "route" },
{ id: "resilience", label: "Resilience", icon: "electrical_services" },
{ id: "advanced", label: "Advanced", icon: "tune" },
{ id: "general", labelKey: "general", icon: "settings" },
{ id: "ai", labelKey: "ai", icon: "smart_toy" },
{ id: "security", labelKey: "security", icon: "shield" },
{ id: "routing", labelKey: "routing", icon: "route" },
{ id: "resilience", labelKey: "resilience", icon: "electrical_services" },
{ id: "advanced", labelKey: "advanced", icon: "tune" },
];
export default function SettingsPage() {
const t = useTranslations("settings");
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const [userSelectedTab, setUserSelectedTab] = useState(null);
@@ -37,7 +39,7 @@ export default function SettingsPage() {
{/* Tab navigation */}
<div
role="tablist"
aria-label="Settings sections"
aria-label={t("settingsSectionsAria")}
className="inline-flex items-center p-1 rounded-lg bg-black/5 dark:bg-white/5 self-start"
>
{tabs.map((tab) => (
@@ -57,13 +59,16 @@ export default function SettingsPage() {
<span className="material-symbols-outlined text-[18px]" aria-hidden="true">
{tab.icon}
</span>
<span className="hidden sm:inline">{tab.label}</span>
<span className="hidden sm:inline">{t(tab.labelKey)}</span>
</button>
))}
</div>
{/* Tab contents */}
<div role="tabpanel" aria-label={tabs.find((t) => t.id === activeTab)?.label}>
<div
role="tabpanel"
aria-label={t(tabs.find((t2) => t2.id === activeTab)?.labelKey || "general")}
>
{activeTab === "general" && (
<>
<div className="flex flex-col gap-6">
@@ -100,7 +105,7 @@ export default function SettingsPage() {
<p>
{APP_CONFIG.name} v{APP_CONFIG.version}
</p>
<p className="mt-1">Local Mode All data stored on your machine</p>
<p className="mt-1">{t("localMode")}</p>
</div>
</div>
</div>

View File

@@ -4,12 +4,14 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Card from "@/shared/components/Card";
import PricingModal from "@/shared/components/PricingModal";
import { useTranslations } from "next-intl";
export default function PricingSettingsPage() {
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [currentPricing, setCurrentPricing] = useState(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("settings");
useEffect(() => {
loadPricing();
@@ -55,92 +57,83 @@ export default function PricingSettingsPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Pricing Settings</h1>
<p className="text-text-muted mt-1">
Configure pricing rates for cost tracking and calculations
</p>
<h1 className="text-3xl font-bold">{t("pricingSettingsTitle")}</h1>
<p className="text-text-muted mt-1">{t("modelPricingDesc")}</p>
</div>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90 transition-colors"
>
Edit Pricing
{t("editPricing")}
</button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">Total Models</div>
<div className="text-text-muted text-sm uppercase font-semibold">{t("totalModels")}</div>
<div className="text-2xl font-bold mt-1">{loading ? "..." : getModelCount()}</div>
</Card>
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">Providers</div>
<div className="text-text-muted text-sm uppercase font-semibold">{t("providers")}</div>
<div className="text-2xl font-bold mt-1">{loading ? "..." : getProviders().length}</div>
</Card>
<Card className="p-4">
<div className="text-text-muted text-sm uppercase font-semibold">Status</div>
<div className="text-2xl font-bold mt-1 text-success">{loading ? "..." : "Active"}</div>
<div className="text-text-muted text-sm uppercase font-semibold">{t("status")}</div>
<div className="text-2xl font-bold mt-1 text-success">
{loading ? "..." : t("active")}
</div>
</Card>
</div>
{/* Info Section */}
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">How Pricing Works</h2>
<h2 className="text-xl font-semibold mb-4">{t("howPricingWorks")}</h2>
<div className="space-y-3 text-sm text-text-muted">
<p>
<strong>Cost Calculation:</strong> Costs are calculated based on token usage and pricing
rates. Each request&apos;s cost is determined by: (input_tokens × input_rate) +
(output_tokens × output_rate) + (cached_tokens × cached_rate)
<strong>{t("costCalculation")}:</strong> {t("costCalculationDesc")}
</p>
<p>
<strong>Pricing Format:</strong> All rates are in{" "}
<strong>dollars per million tokens</strong> ($/1M tokens). Example: An input rate of
2.50 means $2.50 per 1,000,000 input tokens.
<strong>{t("pricingFormat")}:</strong> {t("pricingFormatDesc")}
</p>
<p>
<strong>Token Types:</strong>
<strong>{t("tokenTypes")}:</strong>
</p>
<ul className="list-disc list-inside ml-4 space-y-1">
<li>
<strong>Input:</strong> Standard prompt tokens
<strong>{t("input")}:</strong> {t("inputTokenDesc")}
</li>
<li>
<strong>Output:</strong> Completion/response tokens
<strong>{t("output")}:</strong> {t("outputTokenDesc")}
</li>
<li>
<strong>Cached:</strong> Cached input tokens (typically 50% of input rate)
<strong>{t("cached")}:</strong> {t("cachedTokenDesc")}
</li>
<li>
<strong>Reasoning:</strong> Special reasoning/thinking tokens (fallback to output
rate)
<strong>{t("reasoning")}:</strong> {t("reasoningTokenDesc")}
</li>
<li>
<strong>Cache Creation:</strong> Tokens used to create cache entries (fallback to
input rate)
<strong>{t("cacheCreation")}:</strong> {t("cacheCreationTokenDesc")}
</li>
</ul>
<p>
<strong>Custom Pricing:</strong> You can override default pricing for specific models.
Reset to defaults anytime to restore standard rates.
</p>
<p>{t("customPricingNote")}</p>
</div>
</Card>
{/* Current Pricing Preview */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Current Pricing Overview</h2>
<h2 className="text-xl font-semibold">{t("currentPricing")}</h2>
<button
onClick={() => setShowModal(true)}
className="text-primary hover:underline text-sm"
>
View Full Details
{t("viewFullDetails")}
</button>
</div>
{loading ? (
<div className="text-center py-4 text-text-muted">Loading pricing data...</div>
<div className="text-center py-4 text-text-muted">{t("loadingPricing")}</div>
) : currentPricing ? (
<div className="space-y-3">
{Object.keys(currentPricing)
@@ -149,18 +142,18 @@ export default function PricingSettingsPage() {
<div key={provider} className="text-sm">
<span className="font-semibold">{provider.toUpperCase()}:</span>{" "}
<span className="text-text-muted">
{Object.keys(currentPricing[provider]).length} models
{Object.keys(currentPricing[provider]).length} {t("models")}
</span>
</div>
))}
{Object.keys(currentPricing).length > 5 && (
<div className="text-sm text-text-muted">
+ {Object.keys(currentPricing).length - 5} more providers
+ {t("moreProviders", { count: Object.keys(currentPricing).length - 5 })}
</div>
)}
</div>
) : (
<div className="text-text-muted">No pricing data available</div>
<div className="text-text-muted">{t("noPricing")}</div>
)}
</Card>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import PlaygroundMode from "./components/PlaygroundMode";
@@ -7,26 +9,21 @@ import ChatTesterMode from "./components/ChatTesterMode";
import TestBenchMode from "./components/TestBenchMode";
import LiveMonitorMode from "./components/LiveMonitorMode";
const MODES = [
{ value: "playground", label: "Playground", icon: "code" },
{ value: "chat-tester", label: "Chat Tester", icon: "chat" },
{ value: "test-bench", label: "Test Bench", icon: "science" },
{ value: "live-monitor", label: "Live Monitor", icon: "monitoring" },
];
const MODE_DESCRIPTIONS: Record<string, string> = {
playground:
"Paste any API request body and see how OmniRoute translates it between provider formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API)",
"chat-tester":
"Send real chat requests through OmniRoute and see the full round-trip: your input, the translated request, the provider response, and the translated output",
"test-bench":
"Define multiple test cases with different inputs and expected outputs, run them all at once, and compare results across providers and models",
"live-monitor":
"Watch incoming requests in real-time as they flow through OmniRoute — see format translations happening live and identify issues instantly",
};
export default function TranslatorPageClient() {
const t = useTranslations("translator");
const [mode, setMode] = useState("playground");
const modes = [
{ value: "playground", label: t("playground"), icon: "code" },
{ value: "chat-tester", label: t("chatTester"), icon: "chat" },
{ value: "test-bench", label: t("testBench"), icon: "science" },
{ value: "live-monitor", label: t("liveMonitor"), icon: "monitoring" },
];
const modeDescriptions: Record<string, string> = {
playground: t("modeDescriptionPlayground"),
"chat-tester": t("modeDescriptionChatTester"),
"test-bench": t("modeDescriptionTestBench"),
"live-monitor": t("modeDescriptionLiveMonitor"),
};
return (
<div className="p-8 space-y-6">
@@ -35,14 +32,13 @@ export default function TranslatorPageClient() {
<div>
<h1 className="text-2xl font-bold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">translate</span>
Translator Playground
{t("playgroundTitle")}
</h1>
<p className="text-sm text-text-muted mt-1">
{MODE_DESCRIPTIONS[mode] ||
"Debug, test, and visualize how OmniRoute translates API requests between providers"}
{modeDescriptions[mode] || t("modeDescriptionFallback")}
</p>
</div>
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
<SegmentedControl options={modes} value={mode} onChange={setMode} size="md" />
</div>
{/* Mode Content */}

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useRef } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
@@ -12,7 +14,7 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
/**
* Chat Tester Mode:
* - Left: Chat interface (send messages as a specific client format)
* - Right: Pipeline visualization showing each translation step
* - Right: {t("pipelineVisualization")} showing each translation step
*
* How it works:
* 1. You type a message and select a "Client Format" (how the request is structured)
@@ -22,6 +24,7 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
*/
export default function ChatTesterMode() {
const t = useTranslations("translator");
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
const [clientFormat, setClientFormat] = useState("openai");
@@ -96,8 +99,8 @@ export default function ChatTesterMode() {
steps.push({
id: 1,
name: "Client Request",
description: "The request body as your client would send it",
name: t("clientRequest"),
description: t("clientRequestDescription"),
format: clientFormat,
content: JSON.stringify(clientRequest, null, 2),
status: "done",
@@ -114,8 +117,8 @@ export default function ChatTesterMode() {
steps.push({
id: 2,
name: "Format Detected",
description: "OmniRoute auto-detects the API format from the request structure",
name: t("formatDetected"),
description: t("formatDetectedDescription"),
format: detectedFormat,
content: JSON.stringify(
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
@@ -140,8 +143,8 @@ export default function ChatTesterMode() {
steps.push({
id: 3,
name: "OpenAI Intermediate",
description: "All formats are first normalized to OpenAI format (the universal bridge)",
name: t("openaiIntermediate"),
description: t("openaiIntermediateDescription"),
format: "openai",
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
status: toOpenaiData.success ? "done" : "error",
@@ -163,8 +166,8 @@ export default function ChatTesterMode() {
steps.push({
id: 4,
name: "Provider Format",
description: `OpenAI format is translated to the provider's native format`,
name: t("providerFormat"),
description: t("providerFormatDescription"),
format: targetFmt,
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
status: providerTargetData.success ? "done" : "error",
@@ -178,18 +181,21 @@ export default function ChatTesterMode() {
});
if (!sendRes.ok) {
const errData = await sendRes.json().catch(() => ({ error: "Request failed" }));
const errData = await sendRes.json().catch(() => ({ error: t("requestFailed") }));
steps.push({
id: 5,
name: "Provider Response",
description: "The raw response from the provider API",
name: t("providerResponse"),
description: t("providerResponseRawDescription"),
format: targetFmt,
content: JSON.stringify(errData, null, 2),
status: "error",
});
setChatHistory((prev) => [
...prev,
{ role: "assistant", content: `Error: ${errData.error || "Request failed"}` },
{
role: "assistant",
content: t("errorMessage", { message: errData.error || t("requestFailed") }),
},
]);
} else {
// Read streaming response
@@ -205,8 +211,8 @@ export default function ChatTesterMode() {
steps.push({
id: 5,
name: "Provider Response",
description: "The raw SSE stream from the provider API",
name: t("providerResponse"),
description: t("providerResponseSseDescription"),
format: targetFmt,
content:
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
@@ -217,19 +223,22 @@ export default function ChatTesterMode() {
const assistantText = extractAssistantText(fullResponse);
setChatHistory((prev) => [
...prev,
{ role: "assistant", content: assistantText || "(No text extracted)" },
{ role: "assistant", content: assistantText || t("noTextExtracted") },
]);
}
} catch (err) {
steps.push({
id: steps.length + 1,
name: "Error",
description: "An unexpected error occurred",
name: t("error"),
description: t("unexpectedError"),
format: "error",
content: JSON.stringify({ error: err.message }, null, 2),
status: "error",
});
setChatHistory((prev) => [...prev, { role: "assistant", content: `Error: ${err.message}` }]);
setChatHistory((prev) => [
...prev,
{ role: "assistant", content: t("errorMessage", { message: err.message }) },
]);
}
setPipeline(steps);
@@ -246,14 +255,11 @@ export default function ChatTesterMode() {
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Pipeline Debugger</p>
<p className="font-medium text-text-main mb-0.5">{t("pipelineDebugger")}</p>
<p>{t("chatTesterDescription")}</p>
<p>
Send messages as a specific client format and see how each step of the translation
pipeline works. The right panel shows the full flow:{" "}
<strong className="text-text-main">
Client Request Format Detection OpenAI Intermediate Provider Format Response
</strong>
. Click any step to inspect the data at that stage.
<strong className="text-text-main">{t("chatTesterFlow")}</strong>.{" "}
{t("clickStepToInspect")}
</p>
</div>
</div>
@@ -267,7 +273,7 @@ export default function ChatTesterMode() {
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Client Format
{t("clientFormat")}
</label>
<Select
value={clientFormat}
@@ -279,7 +285,7 @@ export default function ChatTesterMode() {
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Provider
{t("provider")}
</label>
<Select
value={provider}
@@ -290,7 +296,7 @@ export default function ChatTesterMode() {
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
Model
{t("model")}
</label>
<div className="relative">
<input
@@ -298,7 +304,7 @@ export default function ChatTesterMode() {
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-suggestions"
placeholder="Select or type a model name..."
placeholder={t("modelPlaceholder")}
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="model-suggestions">
@@ -319,13 +325,10 @@ export default function ChatTesterMode() {
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
chat
</span>
<p className="text-sm font-medium mb-1">
Send a message to see the translation pipeline
</p>
<p className="text-sm font-medium mb-1">{t("sendMessageToSeePipeline")}</p>
<p className="text-xs text-center max-w-xs">
Your message will be formatted as a{" "}
<strong>{FORMAT_META[clientFormat]?.label}</strong> request, translated through
the pipeline, and sent to the selected provider.
{t("chatMessageHintPrefix")} <strong>{FORMAT_META[clientFormat]?.label}</strong>{" "}
{t("chatMessageHintSuffix")}
</p>
</div>
)}
@@ -343,8 +346,8 @@ export default function ChatTesterMode() {
>
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
{msg.role === "user"
? `You (${FORMAT_META[clientFormat]?.label})`
: "Assistant"}
? t("youWithFormat", { format: FORMAT_META[clientFormat]?.label })
: t("assistant")}
</p>
<p className="whitespace-pre-wrap">{msg.content}</p>
</div>
@@ -361,7 +364,7 @@ export default function ChatTesterMode() {
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="Type a message..."
placeholder={t("typeMessage")}
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
disabled={sending}
/>
@@ -371,7 +374,7 @@ export default function ChatTesterMode() {
loading={sending}
disabled={!message.trim() || sending}
>
Send
{t("send")}
</Button>
</div>
</div>
@@ -386,11 +389,9 @@ export default function ChatTesterMode() {
<span className="material-symbols-outlined text-[18px] text-primary">
account_tree
</span>
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
<h3 className="text-sm font-semibold text-text-main">{t("translationPipeline")}</h3>
</div>
<p className="text-xs text-text-muted">
Click on any step to inspect the data at that stage
</p>
<p className="text-xs text-text-muted">{t("clickStepToInspect")}</p>
</div>
</Card>
@@ -400,11 +401,8 @@ export default function ChatTesterMode() {
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
account_tree
</span>
<p className="text-sm font-medium mb-1">Pipeline visualization</p>
<p className="text-xs text-center max-w-xs">
Send a message to see how your request flows through detection translation
provider call.
</p>
<p className="text-sm font-medium mb-1">{t("pipelineVisualization")}</p>
<p className="text-xs text-center max-w-xs">{t("pipelineVisualizationHint")}</p>
</div>
</Card>
) : (

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useRef } from "react";
import { Card, Badge } from "@/shared/components";
import { FORMAT_META } from "../exampleTemplates";
@@ -10,10 +12,14 @@ import { FORMAT_META } from "../exampleTemplates";
* Polls /api/translator/history for translation events.
*/
export default function LiveMonitorMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const intervalRef = useRef(null);
const notAvailable = t("notAvailableSymbol");
const formatLatency = (value) => t("millisecondsShort", { value });
const fetchHistory = async () => {
try {
@@ -51,27 +57,39 @@ export default function LiveMonitorMode() {
<div className="space-y-5">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
<span
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
aria-hidden="true"
>
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Real-Time Translation Activity</p>
<p className="font-medium text-text-main mb-0.5">{t("realtime")}</p>
<p>
Shows translation events as API calls flow through OmniRoute. Events come from the
in-memory buffer (resets on restart). Use{" "}
<strong className="text-text-main">Chat Tester</strong>,{" "}
<strong className="text-text-main">Test Bench</strong>, or external API calls to
generate events.
{t("liveMonitorDescriptionPrefix")}{" "}
<strong className="text-text-main">{t("chatTester")}</strong>,{" "}
<strong className="text-text-main">{t("testBench")}</strong>
{t("liveMonitorDescriptionSuffix")}
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
<StatCard icon="check_circle" label="Successful" value={successCount} color="green" />
<StatCard icon="error" label="Errors" value={errorCount} color="red" />
<StatCard icon="speed" label="Avg Latency" value={`${avgLatency}ms`} color="purple" />
<StatCard
icon="translate"
label={t("totalTranslations")}
value={events.length}
color="blue"
/>
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
<StatCard
icon="speed"
label={t("avgLatency")}
value={formatLatency(avgLatency)}
color="purple"
/>
</div>
{/* Controls */}
@@ -80,6 +98,7 @@ export default function LiveMonitorMode() {
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[18px] ${autoRefresh ? "text-green-500 animate-pulse" : "text-text-muted"}`}
aria-hidden="true"
>
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
</span>
@@ -87,15 +106,17 @@ export default function LiveMonitorMode() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="text-sm text-text-main hover:text-primary transition-colors"
>
{autoRefresh ? "LiveAuto-refreshing" : "Paused"}
{autoRefresh ? t("liveAutoRefreshing") : t("paused")}
</button>
</div>
<button
onClick={fetchHistory}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
Refresh
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
{tc("refresh")}
</button>
</div>
</Card>
@@ -103,53 +124,53 @@ export default function LiveMonitorMode() {
{/* Events Table */}
<Card>
<div className="p-4">
<h3 className="text-sm font-semibold text-text-main mb-3">Recent Translations</h3>
<h3 className="text-sm font-semibold text-text-main mb-3">{t("recentTranslations")}</h3>
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
Loading...
<span className="material-symbols-outlined animate-spin mr-2" aria-hidden="true">
progress_activity
</span>
{tc("loading")}
</div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
<span
className="material-symbols-outlined text-[48px] mb-3 opacity-30"
aria-hidden="true"
>
monitoring
</span>
<p className="text-sm font-medium mb-1">No translations yet</p>
<p className="text-xs text-center max-w-sm">
Translation events appear here as requests flow through OmniRoute. Use any of these
methods to generate events:
</p>
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
<p className="text-xs text-center max-w-sm">{t("eventsAppearHint")}</p>
<div className="flex flex-wrap gap-2 mt-3 text-xs">
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Chat Tester tab
{t("chatTesterTab")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Test Bench tab
{t("testBenchTab")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
External API calls
{t("externalApiCalls")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
IDE/CLI integrations
{t("ideCliIntegrations")}
</span>
</div>
<p className="text-[10px] mt-3 text-text-muted/70">
Note: Events are stored in-memory and reset when the server restarts.
</p>
<p className="text-[10px] mt-3 text-text-muted/70">{t("inMemoryNote")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">Time</th>
<th className="pb-2 pr-4">Source</th>
<th className="pb-2 pr-4">{t("time")}</th>
<th className="pb-2 pr-4">{t("source")}</th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4">Target</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 text-right">Latency</th>
<th className="pb-2 pr-4">{t("target")}</th>
<th className="pb-2 pr-4">{t("model")}</th>
<th className="pb-2 pr-4">{t("status")}</th>
<th className="pb-2 text-right">{t("latency")}</th>
</tr>
</thead>
<tbody>
@@ -169,7 +190,9 @@ export default function LiveMonitorMode() {
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : "—"}
{event.timestamp
? new Date(event.timestamp).toLocaleTimeString()
: notAvailable}
</td>
<td className="py-2 pr-4">
<Badge variant="default" size="sm">
@@ -177,7 +200,10 @@ export default function LiveMonitorMode() {
</Badge>
</td>
<td className="py-2 pr-4 text-text-muted">
<span className="material-symbols-outlined text-[14px]">
<span
className="material-symbols-outlined text-[14px]"
aria-hidden="true"
>
arrow_forward
</span>
</td>
@@ -187,21 +213,21 @@ export default function LiveMonitorMode() {
</Badge>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-muted">
{event.model || "—"}
{event.model || notAvailable}
</td>
<td className="py-2 pr-4">
{event.status === "success" ? (
<Badge variant="success" size="sm" dot>
OK
{t("ok")}
</Badge>
) : (
<Badge variant="error" size="sm" dot>
{event.statusCode || "ERR"}
{event.statusCode || t("errorShort")}
</Badge>
)}
</td>
<td className="py-2 text-right text-xs text-text-muted">
{event.latency ? `${event.latency}ms` : "—"}
{event.latency ? formatLatency(event.latency) : notAvailable}
</td>
</tr>
);
@@ -221,7 +247,12 @@ function StatCard({ icon, label, value, color }) {
<Card>
<div className="p-4 flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-lg bg-${color}-500/10`}>
<span className={`material-symbols-outlined text-[22px] text-${color}-500`}>{icon}</span>
<span
className={`material-symbols-outlined text-[22px] text-${color}-500`}
aria-hidden="true"
>
{icon}
</span>
</div>
<div>
<p className="text-lg font-bold text-text-main">{value}</p>

View File

@@ -1,13 +1,17 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
export default function PlaygroundMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const [sourceFormat, setSourceFormat] = useState("claude");
const [targetFormat, setTargetFormat] = useState("openai");
const [inputContent, setInputContent] = useState("");
@@ -16,6 +20,7 @@ export default function PlaygroundMode() {
const [translating, setTranslating] = useState(false);
const [detecting, setDetecting] = useState(false);
const [activeTemplate, setActiveTemplate] = useState(null);
const templates = useMemo(() => getExampleTemplates(t), [t]);
// Auto-detect format when input changes
const detectFormatFromInput = useCallback(async (content) => {
@@ -114,12 +119,8 @@ export default function PlaygroundMode() {
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Format Converter</p>
<p>
Paste or type a JSON request body. The translator will auto-detect the source format and
convert it to the target format. Use this to debug how OmniRoute translates requests
between formats (OpenAI Claude Gemini Responses API).
</p>
<p className="font-medium text-text-main mb-0.5">{t("formatConverter")}</p>
<p>{t("formatConverterDescription")}</p>
</div>
</div>
{/* Format Controls Bar */}
@@ -128,7 +129,7 @@ export default function PlaygroundMode() {
{/* Source Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Source Format
{t("source")}
</label>
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[20px] text-${srcMeta.color}-500`}>
@@ -145,7 +146,7 @@ export default function PlaygroundMode() {
/>
{detectedFormat && (
<Badge variant="primary" size="sm" icon="auto_awesome">
Auto
{t("auto")}
</Badge>
)}
</div>
@@ -155,7 +156,7 @@ export default function PlaygroundMode() {
<button
onClick={handleSwapFormats}
className="p-2 rounded-full hover:bg-primary/10 text-text-muted hover:text-primary transition-all mt-4 sm:mt-5"
title="Swap formats"
title={t("swapFormats")}
>
<span className="material-symbols-outlined text-[24px]">swap_horiz</span>
</button>
@@ -163,7 +164,7 @@ export default function PlaygroundMode() {
{/* Target Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Target Format
{t("target")}
</label>
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[20px] text-${tgtMeta.color}-500`}>
@@ -187,7 +188,7 @@ export default function PlaygroundMode() {
disabled={!inputContent.trim() || translating}
className="whitespace-nowrap"
>
Translate
{t("translateAction")}
</Button>
</div>
</div>
@@ -201,7 +202,7 @@ export default function PlaygroundMode() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">input</span>
<h3 className="text-sm font-semibold text-text-main">Input</h3>
<h3 className="text-sm font-semibold text-text-main">{t("input")}</h3>
{detectedFormat && (
<Badge variant="info" size="sm" dot>
{FORMAT_META[detectedFormat]?.label || detectedFormat}
@@ -217,7 +218,7 @@ export default function PlaygroundMode() {
<button
onClick={() => handleCopy(inputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -229,7 +230,7 @@ export default function PlaygroundMode() {
setActiveTemplate(null);
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -250,7 +251,7 @@ export default function PlaygroundMode() {
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
placeholder: "Paste a request body here or select a template below...",
placeholder: t("inputPlaceholder"),
}}
/>
</div>
@@ -265,7 +266,7 @@ export default function PlaygroundMode() {
<span className="material-symbols-outlined text-[18px] text-text-muted">
output
</span>
<h3 className="text-sm font-semibold text-text-main">Output</h3>
<h3 className="text-sm font-semibold text-text-main">{t("output")}</h3>
{outputContent && (
<Badge variant="success" size="sm" dot>
{FORMAT_META[targetFormat]?.label || targetFormat}
@@ -276,7 +277,7 @@ export default function PlaygroundMode() {
<button
onClick={() => handleCopy(outputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -303,18 +304,18 @@ export default function PlaygroundMode() {
</Card>
</div>
{/* Example Templates */}
{/* {t("exampleTemplates")} */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary">
library_books
</span>
<h3 className="text-sm font-semibold text-text-main">Example Templates</h3>
<span className="text-xs text-text-muted"> Click to load</span>
<h3 className="text-sm font-semibold text-text-main">{t("exampleTemplates")}</h3>
<span className="text-xs text-text-muted">{t("exampleTemplatesHint")}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
{EXAMPLE_TEMPLATES.map((template) => (
{templates.map((template) => (
<button
key={template.id}
onClick={() => loadTemplate(template)}
@@ -339,11 +340,9 @@ export default function PlaygroundMode() {
{activeTemplate && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined text-[14px]">info</span>
Template loads the request in{" "}
<strong className="text-text-main">
{FORMAT_META[sourceFormat]?.label || sourceFormat}
</strong>{" "}
format. Change Source Format to load in a different format.
{t("templateLoadHint", {
format: FORMAT_META[sourceFormat]?.label || sourceFormat,
})}
</div>
)}
</div>

View File

@@ -1,8 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
import { useProviderOptions } from "../hooks/useProviderOptions";
import { useAvailableModels } from "../hooks/useAvailableModels";
@@ -17,15 +19,25 @@ import { useAvailableModels } from "../hooks/useAvailableModels";
*/
const SCENARIOS = [
{ id: "simple-chat", name: "Simple Chat", icon: "chat", templateId: "simple-chat" },
{ id: "tool-calling", name: "Tool Calling", icon: "build", templateId: "tool-calling" },
{ id: "multi-turn", name: "Multi-turn", icon: "forum", templateId: "multi-turn" },
{ id: "thinking", name: "Thinking", icon: "psychology", templateId: "thinking" },
{ id: "system-prompt", name: "System Prompt", icon: "settings", templateId: "system-prompt" },
{ id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" },
{ id: "simple-chat", icon: "chat", templateId: "simple-chat" },
{ id: "tool-calling", icon: "build", templateId: "tool-calling" },
{ id: "multi-turn", icon: "forum", templateId: "multi-turn" },
{ id: "thinking", icon: "psychology", templateId: "thinking" },
{ id: "system-prompt", icon: "settings", templateId: "system-prompt" },
{ id: "streaming", icon: "stream", templateId: "streaming" },
];
export default function TestBenchMode() {
const t = useTranslations("translator");
const scenarioLabels: Record<string, string> = {
"simple-chat": t("scenarioSimpleChat"),
"tool-calling": t("scenarioToolCalling"),
"multi-turn": t("scenarioMultiTurn"),
thinking: t("scenarioThinking"),
"system-prompt": t("scenarioSystemPrompt"),
streaming: t("scenarioStreaming"),
};
const templates = useMemo(() => getExampleTemplates(t), [t]);
const [sourceFormat, setSourceFormat] = useState("claude");
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
@@ -44,13 +56,13 @@ export default function TestBenchMode() {
const start = Date.now();
try {
// Find template
const template = EXAMPLE_TEMPLATES.find((t) => t.id === scenario.templateId);
const template = templates.find((item) => item.id === scenario.templateId);
const body = template?.formats[sourceFormat] || template?.formats.openai;
if (!body) {
setResults((prev) => ({
...prev,
[scenario.id]: { status: "error", error: "No template for this format", latency: 0 },
[scenario.id]: { status: "error", error: t("noTemplateForFormat"), latency: 0 },
}));
return;
}
@@ -73,7 +85,7 @@ export default function TestBenchMode() {
...prev,
[scenario.id]: {
status: "error",
error: `Translation failed: ${translateData.error}`,
error: t("translationFailed", { error: translateData.error }),
latency: Date.now() - start,
},
}));
@@ -147,13 +159,8 @@ export default function TestBenchMode() {
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
<p>
Run predefined scenarios (Simple Chat, Tool Calling, etc.) to verify translation and
provider compatibility. Select a source format and target provider, then run all tests
to see a compatibility percentage. Use this to find which features work across
providers.
</p>
<p className="font-medium text-text-main mb-0.5">{t("compatibilityTester")}</p>
<p>{t("testBenchDescription")}</p>
</div>
</div>
@@ -163,7 +170,7 @@ export default function TestBenchMode() {
<div className="flex flex-col sm:flex-row items-end gap-4">
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Source Format
{t("source")}
</label>
<Select
value={sourceFormat}
@@ -183,7 +190,7 @@ export default function TestBenchMode() {
</div>
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Target Provider
{t("targetProvider")}
</label>
<Select
value={provider}
@@ -200,12 +207,12 @@ export default function TestBenchMode() {
loading={runningAll}
disabled={runningAll}
>
Run All Tests
{t("runAllTests")}
</Button>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
{t("model")}
</label>
<div className="relative">
<input
@@ -213,7 +220,7 @@ export default function TestBenchMode() {
value={model}
onChange={(e) => setModel(e.target.value)}
list="testbench-model-suggestions"
placeholder="Select or type a model name..."
placeholder={t("modelPlaceholder")}
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="testbench-model-suggestions">
@@ -232,7 +239,7 @@ export default function TestBenchMode() {
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-text-main">Compatibility Report</h3>
<h3 className="text-sm font-semibold text-text-main">{t("compatibilityReport")}</h3>
<Badge
variant={
compatibility >= 80 ? "success" : compatibility >= 50 ? "warning" : "error"
@@ -244,10 +251,10 @@ export default function TestBenchMode() {
</div>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> {passCount} passed
<span className="size-2 rounded-full bg-green-500" /> {passCount} {t("passed")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> {failCount} failed
<span className="size-2 rounded-full bg-red-500" /> {failCount} {t("failed")}
</span>
</div>
</div>
@@ -296,7 +303,9 @@ export default function TestBenchMode() {
</span>
</div>
<div>
<p className="text-sm font-medium text-text-main">{scenario.name}</p>
<p className="text-sm font-medium text-text-main">
{scenarioLabels[scenario.id] || scenario.id}
</p>
<p className="text-[10px] text-text-muted uppercase">
{srcMeta.label} {" "}
{providerOptions.find((o) => o.value === provider)?.label || provider}
@@ -312,9 +321,9 @@ export default function TestBenchMode() {
>
{result.status === "pass" ? (
<div className="flex items-center justify-between">
<span> Passed</span>
<span>{t("passedIconLabel")}</span>
<span className="text-text-muted">
{result.latency}ms {result.chunks} chunks
{result.latency}ms {result.chunks} {t("chunks")}
</span>
</div>
) : (
@@ -334,7 +343,7 @@ export default function TestBenchMode() {
disabled={isRunning || runningAll}
className="w-full"
>
{isRunning ? "Running..." : result ? "Re-run" : "Run Test"}
{isRunning ? t("running") : result ? t("reRun") : t("runTest")}
</Button>
</div>
</Card>

View File

@@ -4,293 +4,295 @@
* quickly load a realistic payload and see how the translator converts it.
*/
export const EXAMPLE_TEMPLATES = [
{
id: "simple-chat",
name: "Simple Chat",
icon: "chat",
description: "Basic text message",
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello! How are you today?" },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: "You are a helpful assistant.",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello! How are you today?" }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: "Hello! How are you today?" }],
type TranslatorMessage = (key: string) => string;
export function getExampleTemplates(t: TranslatorMessage) {
const simpleChatSystem = t("templatePayloads.simpleChat.system");
const simpleChatUser = t("templatePayloads.simpleChat.userGreeting");
const toolUserWeather = t("templatePayloads.toolCalling.userWeather");
const toolDescription = t("templatePayloads.toolCalling.toolDescription");
const cityNameDescription = t("templatePayloads.toolCalling.cityNameDescription");
const multiTurnSystem = t("templatePayloads.multiTurn.system");
const multiTurnUserInitial = t("templatePayloads.multiTurn.userInitial");
const multiTurnAssistantExample = t("templatePayloads.multiTurn.assistantExample");
const multiTurnUserFollowUp = t("templatePayloads.multiTurn.userFollowUp");
const thinkingQuestion = t("templatePayloads.thinking.question");
const systemPromptInstruction = t("templatePayloads.systemPrompt.systemInstruction");
const systemPromptQuestion = t("templatePayloads.systemPrompt.question");
const streamingPrompt = t("templatePayloads.streaming.prompt");
return [
{
id: "simple-chat",
name: t("templateNames.simple-chat"),
icon: "chat",
description: t("templateDescriptions.simple-chat"),
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: simpleChatSystem },
{ role: "user", content: simpleChatUser },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: simpleChatSystem,
max_tokens: 1024,
messages: [{ role: "user", content: simpleChatUser }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: simpleChatUser }],
},
],
systemInstruction: {
parts: [{ text: simpleChatSystem }],
},
],
systemInstruction: {
parts: [{ text: "You are a helpful assistant." }],
},
"openai-responses": {
model: "gpt-4o",
input: simpleChatUser,
instructions: simpleChatSystem,
},
},
"openai-responses": {
model: "gpt-4o",
input: "Hello! How are you today?",
instructions: "You are a helpful assistant.",
},
},
},
{
id: "tool-calling",
name: "Tool Calling",
icon: "build",
description: "Function/tool invocation",
formats: {
openai: {
model: "gpt-4o",
messages: [{ role: "user", content: "What's the weather in São Paulo?" }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
},
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: "What's the weather in São Paulo?" }],
tools: [
{
name: "get_weather",
description: "Get current weather for a location",
input_schema: {
type: "object",
properties: {
location: { type: "string", description: "City name" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: "What's the weather in São Paulo?" }],
},
],
tools: [
{
functionDeclarations: [
{
{
id: "tool-calling",
name: t("templateNames.tool-calling"),
icon: "build",
description: t("templateDescriptions.tool-calling"),
formats: {
openai: {
model: "gpt-4o",
messages: [{ role: "user", content: toolUserWeather }],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get current weather for a location",
description: toolDescription,
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" },
location: { type: "string", description: cityNameDescription },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
},
],
},
},
},
{
id: "multi-turn",
name: "Multi-turn",
icon: "forum",
description: "Conversation with history",
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a coding assistant." },
{ role: "user", content: "Write a function to sort an array in Python." },
{
role: "assistant",
content:
"Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
},
{ role: "user", content: "Now make it sort in descending order." },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: "You are a coding assistant.",
max_tokens: 1024,
messages: [
{ role: "user", content: "Write a function to sort an array in Python." },
{
role: "assistant",
content:
"Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
},
{ role: "user", content: "Now make it sort in descending order." },
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{ role: "user", parts: [{ text: "Write a function to sort an array in Python." }] },
{
role: "model",
parts: [
{
text: "Here's a simple sort function:\n\n```python\ndef sort_array(arr):\n return sorted(arr)\n```",
},
],
},
{ role: "user", parts: [{ text: "Now make it sort in descending order." }] },
],
systemInstruction: {
parts: [{ text: "You are a coding assistant." }],
},
],
stream: true,
},
},
},
},
{
id: "thinking",
name: "Thinking",
icon: "psychology",
description: "Extended thinking / reasoning",
formats: {
openai: {
model: "o3-mini",
messages: [{ role: "user", content: "What is the sum of the first 100 prime numbers?" }],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 16000,
thinking: {
type: "enabled",
budget_tokens: 10000,
},
messages: [{ role: "user", content: "What is the sum of the first 100 prime numbers?" }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash-thinking",
contents: [
{ role: "user", parts: [{ text: "What is the sum of the first 100 prime numbers?" }] },
],
generationConfig: {
thinkingConfig: {
thinkingBudget: 10000,
},
},
},
},
},
{
id: "system-prompt",
name: "System Prompt",
icon: "settings",
description: "Complex system instructions",
formats: {
openai: {
model: "gpt-4o",
messages: [
{
role: "system",
content:
"You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
},
{ role: "user", content: "How do I implement a circuit breaker pattern?" },
],
temperature: 0.7,
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system:
"You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
max_tokens: 2048,
messages: [{ role: "user", content: "How do I implement a circuit breaker pattern?" }],
temperature: 0.7,
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{ role: "user", parts: [{ text: "How do I implement a circuit breaker pattern?" }] },
],
systemInstruction: {
parts: [
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: toolUserWeather }],
tools: [
{
text: "You are a senior software engineer specializing in distributed systems. Answer questions concisely using industry best practices. Always provide code examples when relevant. Format your responses using markdown.",
name: "get_weather",
description: toolDescription,
input_schema: {
type: "object",
properties: {
location: { type: "string", description: cityNameDescription },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: toolUserWeather }],
},
],
tools: [
{
functionDeclarations: [
{
name: "get_weather",
description: toolDescription,
parameters: {
type: "object",
properties: {
location: { type: "string", description: cityNameDescription },
unit: { type: "string", enum: ["celsius", "fahrenheit"] },
},
required: ["location"],
},
},
],
},
],
},
generationConfig: {
temperature: 0.7,
},
},
{
id: "multi-turn",
name: t("templateNames.multi-turn"),
icon: "forum",
description: t("templateDescriptions.multi-turn"),
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "system", content: multiTurnSystem },
{ role: "user", content: multiTurnUserInitial },
{
role: "assistant",
content: multiTurnAssistantExample,
},
{ role: "user", content: multiTurnUserFollowUp },
],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: multiTurnSystem,
max_tokens: 1024,
messages: [
{ role: "user", content: multiTurnUserInitial },
{
role: "assistant",
content: multiTurnAssistantExample,
},
{ role: "user", content: multiTurnUserFollowUp },
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{ role: "user", parts: [{ text: multiTurnUserInitial }] },
{
role: "model",
parts: [
{
text: multiTurnAssistantExample,
},
],
},
{ role: "user", parts: [{ text: multiTurnUserFollowUp }] },
],
systemInstruction: {
parts: [{ text: multiTurnSystem }],
},
},
},
},
},
{
id: "streaming",
name: "Streaming",
icon: "stream",
description: "SSE streaming request",
formats: {
openai: {
model: "gpt-4o",
messages: [
{ role: "user", content: "Tell me a short story about a robot learning to paint." },
],
stream: true,
stream_options: { include_usage: true },
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [
{ role: "user", content: "Tell me a short story about a robot learning to paint." },
],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: "Tell me a short story about a robot learning to paint." }],
{
id: "thinking",
name: t("templateNames.thinking"),
icon: "psychology",
description: t("templateDescriptions.thinking"),
formats: {
openai: {
model: "o3-mini",
messages: [{ role: "user", content: thinkingQuestion }],
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 16000,
thinking: {
type: "enabled",
budget_tokens: 10000,
},
],
messages: [{ role: "user", content: thinkingQuestion }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash-thinking",
contents: [{ role: "user", parts: [{ text: thinkingQuestion }] }],
generationConfig: {
thinkingConfig: {
thinkingBudget: 10000,
},
},
},
},
},
},
];
{
id: "system-prompt",
name: t("templateNames.system-prompt"),
icon: "settings",
description: t("templateDescriptions.system-prompt"),
formats: {
openai: {
model: "gpt-4o",
messages: [
{
role: "system",
content: systemPromptInstruction,
},
{ role: "user", content: systemPromptQuestion },
],
temperature: 0.7,
stream: true,
},
claude: {
model: "claude-sonnet-4-20250514",
system: systemPromptInstruction,
max_tokens: 2048,
messages: [{ role: "user", content: systemPromptQuestion }],
temperature: 0.7,
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [{ role: "user", parts: [{ text: systemPromptQuestion }] }],
systemInstruction: {
parts: [{ text: systemPromptInstruction }],
},
generationConfig: {
temperature: 0.7,
},
},
},
},
{
id: "streaming",
name: t("templateNames.streaming"),
icon: "stream",
description: t("templateDescriptions.streaming"),
formats: {
openai: {
model: "gpt-4o",
messages: [{ role: "user", content: streamingPrompt }],
stream: true,
stream_options: { include_usage: true },
},
claude: {
model: "claude-sonnet-4-20250514",
max_tokens: 1024,
messages: [{ role: "user", content: streamingPrompt }],
stream: true,
},
gemini: {
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: streamingPrompt }],
},
],
},
},
},
];
}
/**
* Format metadata for display: colors, labels, icons

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import {
AI_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
@@ -16,6 +17,7 @@ import {
* @returns {{ provider: string, setProvider: Function, providerOptions: Array<{value: string, label: string}>, loading: boolean }}
*/
export function useProviderOptions(initialProvider = "openai") {
const t = useTranslations("translator");
const [provider, setProvider] = useState(initialProvider);
const [providerOptions, setProviderOptions] = useState([]);
const [loading, setLoading] = useState(true);
@@ -38,9 +40,9 @@ export function useProviderOptions(initialProvider = "openai") {
const node: any = nodeMap.get(pid);
let label = info?.name || node?.name || pid;
if (!info && (pid as string).startsWith(OPENAI_COMPATIBLE_PREFIX))
label = node?.name || "OpenAI Compatible";
label = node?.name || t("openaiCompatibleLabel");
if (!info && (pid as string).startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
label = node?.name || "Anthropic Compatible";
label = node?.name || t("anthropicCompatibleLabel");
return { value: pid, label };
})
.sort((a, b) => a.label.localeCompare(b.label));
@@ -48,11 +50,16 @@ export function useProviderOptions(initialProvider = "openai") {
const nextOptions =
options.length > 0
? options
: Object.entries(AI_PROVIDERS).map(([id, info]: [string, any]) => ({ value: id, label: info.name }));
: Object.entries(AI_PROVIDERS).map(([id, info]: [string, any]) => ({
value: id,
label: info.name,
}));
setProviderOptions(nextOptions);
if (nextOptions.length > 0) {
setProvider((current: string): string =>
nextOptions.some((opt: any) => opt.value === current) ? current : (nextOptions[0] as any).value as string
nextOptions.some((opt: any) => opt.value === current)
? current
: ((nextOptions[0] as any).value as string)
);
}
} catch {
@@ -73,7 +80,7 @@ export function useProviderOptions(initialProvider = "openai") {
}
};
fetchProviders();
}, []);
}, [t]);
return { provider, setProvider, providerOptions, loading };
}

View File

@@ -1,9 +1,13 @@
import TranslatorPageClient from "./TranslatorPageClient";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: "Translator Playground | OmniRoute",
description: "Debug, test, and visualize API format translations between providers",
};
export async function generateMetadata() {
const t = await getTranslations("translator");
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
export default function TranslatorPage() {
return <TranslatorPageClient />;

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