Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 113ac1c940 | |||
| fbaf30a6bf | |||
| 8abdf68718 | |||
| 67fa2592b5 | |||
| fed445c991 | |||
| 62069dac98 | |||
| 5a65585c16 | |||
| 4e3b363ba6 | |||
| f1d421bd8a | |||
| fb840d6392 | |||
| e659d2ee69 | |||
| ab1b0c890a | |||
| dbe6a4e30c | |||
| ce1e10c8c6 | |||
| 8caef4b688 | |||
| bc55911d0f | |||
| bc6b084c77 | |||
| 3d86ad7dc8 | |||
| 0844659e00 | |||
| e07edc663b | |||
| 5f38173387 | |||
| 481a630273 | |||
| 8592d02951 | |||
| 7f34835693 | |||
| 0ac264b39d | |||
| 87c7c83dd9 | |||
| 10d3120cdf | |||
| 27b9c331b7 | |||
| e1fe304dd3 | |||
| d226d68251 | |||
| a6014524ef | |||
| 1a98a6c966 | |||
| 0d13f4645c | |||
| 88d5986ac1 | |||
| f7fb68a798 | |||
| 5811e677f1 | |||
| 0d9a98c4e1 | |||
| 05d8d3d71d | |||
| f670a1e451 | |||
| 1591737528 | |||
| f74a007e27 | |||
| eb290a90cb | |||
| 9b80f723df | |||
| f2ace011ff | |||
| 0c0a56d4de | |||
| 981d163278 | |||
| 96cdd9bccb | |||
| 354d0b5f09 | |||
| 3d2de04dd1 | |||
| 93a220ba83 | |||
| 779957526b | |||
| f76482db87 | |||
| 1be20a4e2d | |||
| ff965234c9 | |||
| 62facba06f | |||
| 3c309f1fa4 | |||
| 090343aa01 | |||
| e674e5d87b | |||
| 2a90a05132 | |||
| a0af564b5a | |||
| ca2b1faa72 | |||
| bf49fdf0bf | |||
| c8989ddead | |||
| 4ea0426034 | |||
| 619c99ce4c | |||
| 86c566669c | |||
| 9aad413809 | |||
| 6afcebabab | |||
| ad1cc64e5a | |||
| 243cc4b60b | |||
| ddb02d6464 | |||
| f24abf074b | |||
| ff01e9edaa | |||
| 168b17adc7 | |||
| 7e0c6f0307 | |||
| dd573aed6f | |||
| b87af5d053 | |||
| 29b3e59d23 | |||
| ce6d7dc6bf | |||
| 19eeebae95 | |||
| 1dd05bffe8 | |||
| ac3d251a1a | |||
| 238e080928 | |||
| 7ed40c2139 | |||
| d2bee37e76 | |||
| 343e6c50e3 | |||
| 90d2dcac97 | |||
| 631ed4d97f | |||
| 9485985608 | |||
| b32db28a3d | |||
| 1516429b87 | |||
| 5668e16fbf | |||
| bd4a076942 | |||
| f0a0c97b5e | |||
| 7ffe21e23d | |||
| 4b137d8e72 | |||
| 6ba48241fe | |||
| a17583d3fc | |||
| 62c634ae78 | |||
| 02dc8ea0f3 | |||
| a40c463a87 | |||
| 5e0376c6c9 | |||
| 2bad777541 | |||
| 10793a7e65 | |||
| 5dab4058c8 | |||
| b26cc2a82e | |||
| 0610909116 | |||
| f8a401da4b | |||
| 3b70e9f786 | |||
| 39f992a2a8 | |||
| 0df1d46ae5 | |||
| 28c7e0d62f | |||
| 03965bf768 |
@@ -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 |
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,22 +1,22 @@
|
||||
name: Codex PR Review
|
||||
# name: Codex PR Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# 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'
|
||||
});
|
||||
# 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'
|
||||
# });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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' }}
|
||||
name: Deploy OmniRoute to VPS
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
with:
|
||||
oauth-client-id: ""
|
||||
oauth-secret: ""
|
||||
tags: tag:ci-deploy
|
||||
continue-on-error: true
|
||||
|
||||
- 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 ==="
|
||||
@@ -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
|
||||
|
||||
@@ -95,3 +95,5 @@ security-analysis/
|
||||
# Deploy workflow (contains sensitive VPS credentials)
|
||||
.agent/workflows/deploy.md
|
||||
clipr/
|
||||
app.log
|
||||
*.tgz
|
||||
|
||||
+279
-1
@@ -7,6 +7,271 @@ 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
|
||||
>
|
||||
> Fixes missing remote-server OAuth configurations and adds ChatGPT Business account quota monitoring.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **OAuth Client Secret** — Omitted explicitly empty `client_secret` parameters to resolve token exchange connection rejection on remote servers missing environment variables for Antigravity, Gemini and iFlow ([#103](https://github.com/diegosouzapw/OmniRoute/issues/103))
|
||||
- **Codex Business Quotas** — Automatically fetches the appropriate ChatGPT workspace to unlock the 5-hour Business usage limits directly inside the Quota tab and mapped `BIZ` string variant perfectly ([#101](https://github.com/diegosouzapw/OmniRoute/issues/101))
|
||||
|
||||
---
|
||||
|
||||
## [1.0.10] — 2026-02-21
|
||||
|
||||
> ### 🐛 Bugfix — Multi-Account Support for Qwen
|
||||
@@ -244,7 +509,7 @@ New environment variables:
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] — 2026-02-18
|
||||
## [1.0.1] — 2026-02-18
|
||||
|
||||
> ### 🔧 API Compatibility & SDK Hardening
|
||||
>
|
||||
@@ -424,11 +689,24 @@ 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
|
||||
[1.0.4]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.4
|
||||
[1.1.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.0
|
||||
[1.0.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.1
|
||||
[1.0.3]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.3
|
||||
[1.0.2]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.2
|
||||
[1.0.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.0
|
||||
|
||||
@@ -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._
|
||||
@@ -393,14 +395,15 @@ 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 |
|
||||
|
||||
<details>
|
||||
<summary><b>📖 Feature Details</b></summary>
|
||||
@@ -1009,7 +1012,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 18–22 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)
|
||||
@@ -1145,6 +1148,85 @@ 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/>
|
||||
|
||||
@@ -399,6 +399,7 @@ 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) |
|
||||
|
||||
<details>
|
||||
<summary><b>📖 Detalhes das Funcionalidades</b></summary>
|
||||
|
||||
+20
-8
@@ -81,17 +81,26 @@ 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");
|
||||
|
||||
if (!existsSync(serverJs)) {
|
||||
console.error(
|
||||
"\x1b[31m✖ Server not found at:\x1b[0m",
|
||||
serverJs,
|
||||
);
|
||||
console.error(
|
||||
" This usually means the package was not built correctly.",
|
||||
);
|
||||
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
|
||||
console.error(" This usually means the package was not built correctly.");
|
||||
console.error(" Try reinstalling: npm install -g omniroute");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -119,7 +128,10 @@ server.stdout.on("data", (data) => {
|
||||
process.stdout.write(text);
|
||||
|
||||
// Detect server ready
|
||||
if (!started && (text.includes("Ready") || text.includes("started") || text.includes("listening"))) {
|
||||
if (
|
||||
!started &&
|
||||
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
|
||||
) {
|
||||
started = true;
|
||||
onReady();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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**
|
||||
+7
-3
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
+86
-41
@@ -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,15 +480,26 @@ 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 {
|
||||
// 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) {
|
||||
headers["chatgpt-account-id"] = accountId;
|
||||
}
|
||||
|
||||
const response = await fetch(CODEX_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -500,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}`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+947
-232
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.0.10",
|
||||
"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": "next start --port 20128",
|
||||
"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",
|
||||
|
||||
@@ -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,8 @@ 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 [providerConnections, setProviderConnections] = useState([]);
|
||||
const [models, setModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -103,14 +107,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: t("documentation"), href: "/docs", icon: "menu_book" },
|
||||
{ label: tc("provider") + "s", 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: t("healthMonitor"), href: "/dashboard/health", icon: "health_and_safety" },
|
||||
{ label: "CLI Tools", 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 +139,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,7 +157,7 @@ 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">
|
||||
@@ -170,7 +172,7 @@ 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">
|
||||
@@ -185,7 +187,7 @@ 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>
|
||||
<span className="font-semibold">{t("step3Title")}</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">
|
||||
@@ -200,7 +202,7 @@ 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">
|
||||
@@ -239,7 +241,7 @@ 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
|
||||
@@ -297,6 +299,7 @@ HomePageClient.propTypes = {
|
||||
|
||||
function ProviderOverviewCard({ item, metrics, onClick }) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const tc = useTranslations("common");
|
||||
|
||||
const statusVariant =
|
||||
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
|
||||
@@ -348,7 +351,7 @@ function ProviderOverviewCard({ item, metrics, onClick }) {
|
||||
</div>
|
||||
<p className={`text-xs ${statusVariant}`}>
|
||||
{item.total === 0
|
||||
? "Not configured"
|
||||
? tc("notConfigured")
|
||||
: `${item.connected} active · ${item.errors} error`}
|
||||
</p>
|
||||
{metrics && metrics.totalRequests > 0 && (
|
||||
@@ -401,6 +404,8 @@ 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 navigateTo = (path) => {
|
||||
onClose();
|
||||
@@ -433,7 +438,7 @@ 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
|
||||
@@ -460,7 +465,7 @@ function ProviderModelsModal({ provider, models, onClose }) {
|
||||
<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,7 +486,7 @@ 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
|
||||
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
import ApiManagerPageClient from "./ApiManagerPageClient";
|
||||
|
||||
export default function ApiManagerPage() {
|
||||
return <ApiManagerPageClient />;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -87,12 +90,8 @@ 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}
|
||||
@@ -100,7 +99,7 @@ export default function AuditLogPage() {
|
||||
aria-label="Refresh audit log"
|
||||
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>
|
||||
|
||||
@@ -112,7 +111,7 @@ export default function AuditLogPage() {
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by action..."
|
||||
placeholder={t("filterByAction")}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
@@ -121,7 +120,7 @@ export default function AuditLogPage() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by actor..."
|
||||
placeholder={t("filterByActor")}
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
@@ -132,7 +131,7 @@ export default function AuditLogPage() {
|
||||
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>
|
||||
|
||||
@@ -152,22 +151,22 @@ export default function AuditLogPage() {
|
||||
<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,9 +193,7 @@ 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 || "—"}
|
||||
</td>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -64,13 +66,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);
|
||||
}
|
||||
@@ -268,11 +274,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>
|
||||
|
||||
@@ -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">
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 || []);
|
||||
@@ -280,41 +274,6 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
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`;
|
||||
|
||||
@@ -361,12 +320,14 @@ export default function APIPageClient({ machineId }) {
|
||||
<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 +340,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 +350,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,107 +396,19 @@ 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{" "}
|
||||
{Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)} models
|
||||
across{" "}
|
||||
{
|
||||
[
|
||||
endpointData.chat,
|
||||
@@ -558,9 +431,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 +447,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 +465,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 +481,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 +497,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 +517,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 +535,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={() =>
|
||||
@@ -744,19 +617,19 @@ export default function APIPageClient({ machineId }) {
|
||||
{/* 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>
|
||||
|
||||
@@ -795,9 +668,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 +683,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 +700,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">
|
||||
@@ -923,14 +735,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 +756,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 +768,7 @@ export default function APIPageClient({ machineId }) {
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
>
|
||||
Cancel
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -979,83 +791,16 @@ 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 }) {
|
||||
// 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);
|
||||
@@ -1151,7 +896,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);
|
||||
@@ -1216,7 +963,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) => (
|
||||
|
||||
@@ -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);
|
||||
@@ -38,6 +39,7 @@ const CB_COLORS = {
|
||||
};
|
||||
|
||||
export default function HealthPage() {
|
||||
const t = useTranslations("health");
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
@@ -84,12 +86,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" });
|
||||
@@ -111,7 +108,7 @@ export default function HealthPage() {
|
||||
<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 +119,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,10 +140,8 @@ 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 && (
|
||||
@@ -185,7 +180,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 +191,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,7 +201,7 @@ 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>
|
||||
@@ -217,13 +212,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>
|
||||
@@ -248,7 +243,7 @@ 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">
|
||||
@@ -265,12 +260,12 @@ export default function HealthPage() {
|
||||
<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 +273,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,7 +303,7 @@ 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">
|
||||
@@ -340,7 +335,7 @@ 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>
|
||||
@@ -352,7 +347,7 @@ export default function HealthPage() {
|
||||
<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") && (
|
||||
@@ -371,12 +366,12 @@ export default function HealthPage() {
|
||||
<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 +379,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,7 +403,7 @@ 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;
|
||||
@@ -461,7 +454,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">
|
||||
@@ -548,7 +541,7 @@ 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" : ""}
|
||||
@@ -634,7 +627,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]) => (
|
||||
|
||||
@@ -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);
|
||||
@@ -85,10 +87,8 @@ 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}
|
||||
@@ -96,7 +96,7 @@ export default function AuditLogTab() {
|
||||
aria-label="Refresh audit log"
|
||||
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>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function AuditLogTab() {
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by action..."
|
||||
placeholder={t("filterByAction")}
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
@@ -117,7 +117,7 @@ export default function AuditLogTab() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by actor..."
|
||||
placeholder={t("filterByActor")}
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
@@ -128,7 +128,7 @@ export default function AuditLogTab() {
|
||||
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>
|
||||
|
||||
@@ -148,19 +148,19 @@ export default function AuditLogTab() {
|
||||
<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)]">IP</th>
|
||||
</tr>
|
||||
@@ -169,7 +169,7 @@ export default function AuditLogTab() {
|
||||
{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>
|
||||
) : (
|
||||
@@ -216,14 +216,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -60,6 +58,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;
|
||||
|
||||
@@ -88,12 +92,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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,18 +127,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");
|
||||
@@ -142,21 +146,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"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,7 +180,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>
|
||||
);
|
||||
}
|
||||
@@ -234,15 +238,14 @@ export default function OnboardingWizard() {
|
||||
{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.
|
||||
<strong className="text-text-main">OmniRoute</strong>{" "}
|
||||
{t("welcomeDesc").replace("OmniRoute is your local AI API proxy. ", "")}
|
||||
</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}
|
||||
@@ -261,9 +264,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"
|
||||
@@ -271,26 +272,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>
|
||||
)}
|
||||
@@ -300,9 +301,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
|
||||
@@ -325,14 +324,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"
|
||||
@@ -345,15 +344,13 @@ export default function OnboardingWizard() {
|
||||
{/* Test */}
|
||||
{currentStep.id === "test" && (
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
Let'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" && (
|
||||
@@ -380,7 +377,7 @@ export default function OnboardingWizard() {
|
||||
onClick={handleTestProvider}
|
||||
className="text-xs text-text-muted underline cursor-pointer"
|
||||
>
|
||||
Retry
|
||||
{t("retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -390,12 +387,9 @@ export default function OnboardingWizard() {
|
||||
{/* Done */}
|
||||
{currentStep.id === "done" && (
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-text-muted">
|
||||
You'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">http://localhost:20128/api/v1</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,7 +404,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>
|
||||
@@ -420,7 +414,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" && (
|
||||
@@ -428,7 +422,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" && (
|
||||
@@ -437,7 +431,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" && (
|
||||
@@ -446,7 +440,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" && (
|
||||
@@ -454,7 +448,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 && (
|
||||
@@ -462,7 +456,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>
|
||||
@@ -476,7 +470,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>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import PropTypes from "prop-types";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -46,6 +48,7 @@ export default function ProviderDetailPage() {
|
||||
const [modelAliases, setModelAliases] = useState({});
|
||||
const [headerImgError, setHeaderImgError] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const t = useTranslations("providers");
|
||||
const hasAutoOpened = useRef(false);
|
||||
const userDismissed = useRef(false);
|
||||
const [proxyTarget, setProxyTarget] = useState(null);
|
||||
@@ -202,7 +205,7 @@ export default function ProviderDetailPage() {
|
||||
await fetchAliases();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "Failed to set alias");
|
||||
alert(data.error || t("failedSetAlias"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error setting alias:", error);
|
||||
@@ -223,7 +226,7 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm("Delete this connection?")) return;
|
||||
if (!confirm(t("deleteConnectionConfirm"))) return;
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
@@ -252,11 +255,11 @@ export default function ProviderDetailPage() {
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const errorMsg = data.error?.message || data.error || "Failed to save connection";
|
||||
const errorMsg = data.error?.message || data.error || t("failedSaveConnection");
|
||||
return errorMsg;
|
||||
} catch (error) {
|
||||
console.log("Error saving connection:", error);
|
||||
return "Failed to save connection. Please try again.";
|
||||
return t("failedSaveConnectionRetry");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,7 +318,7 @@ export default function ProviderDetailPage() {
|
||||
const res = await fetch(`/api/providers/${connectionId}/test`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
alert(data.error || "Failed to retest connection");
|
||||
alert(data.error || t("failedRetestConnection"));
|
||||
return;
|
||||
}
|
||||
await fetchConnections();
|
||||
@@ -374,7 +377,7 @@ export default function ProviderDetailPage() {
|
||||
current: 0,
|
||||
total: 0,
|
||||
phase: "fetching",
|
||||
status: "Fetching available models...",
|
||||
status: t("fetchingModels"),
|
||||
logs: [],
|
||||
error: "",
|
||||
importedCount: 0,
|
||||
@@ -387,7 +390,7 @@ export default function ProviderDetailPage() {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: "Failed to fetch models",
|
||||
status: t("failedFetchModels"),
|
||||
error: data.error || "Failed to import models",
|
||||
}));
|
||||
return;
|
||||
@@ -397,7 +400,7 @@ export default function ProviderDetailPage() {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
status: "No models found",
|
||||
status: t("noModelsFound"),
|
||||
logs: ["No models returned from /models endpoint."],
|
||||
}));
|
||||
return;
|
||||
@@ -474,7 +477,7 @@ export default function ProviderDetailPage() {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: "Import failed",
|
||||
status: t("importFailed"),
|
||||
error: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
}));
|
||||
} finally {
|
||||
@@ -492,7 +495,7 @@ export default function ProviderDetailPage() {
|
||||
current: 0,
|
||||
total: 0,
|
||||
phase: "fetching",
|
||||
status: "Fetching available models...",
|
||||
status: t("fetchingModels"),
|
||||
logs: [],
|
||||
error: "",
|
||||
importedCount: 0,
|
||||
@@ -505,7 +508,7 @@ export default function ProviderDetailPage() {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
status: "No models found",
|
||||
status: t("noModelsFound"),
|
||||
logs: ["No models returned from /models endpoint."],
|
||||
}));
|
||||
return;
|
||||
@@ -563,7 +566,7 @@ export default function ProviderDetailPage() {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: "Import failed",
|
||||
status: t("importFailed"),
|
||||
error: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
}));
|
||||
}
|
||||
@@ -599,10 +602,10 @@ export default function ProviderDetailPage() {
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? "Importing..." : "Import from /models"}
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">Add a connection to enable importing.</span>
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
<PassthroughModelsSection
|
||||
@@ -626,10 +629,10 @@ export default function ProviderDetailPage() {
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? "Importing..." : "Import from /models"}
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">Add a connection to enable importing.</span>
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -638,7 +641,7 @@ export default function ProviderDetailPage() {
|
||||
return (
|
||||
<div>
|
||||
{importButton}
|
||||
<p className="text-sm text-text-muted">No models configured</p>
|
||||
<p className="text-sm text-text-muted">{t("noModelsConfigured")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -682,9 +685,9 @@ export default function ProviderDetailPage() {
|
||||
if (!providerInfo) {
|
||||
return (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-text-muted">Provider not found</p>
|
||||
<p className="text-text-muted">{t("providerNotFound")}</p>
|
||||
<Link href="/dashboard/providers" className="text-primary mt-4 inline-block">
|
||||
Back to Providers
|
||||
{t("backToProviders")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -712,7 +715,7 @@ export default function ProviderDetailPage() {
|
||||
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>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
@@ -763,15 +766,15 @@ export default function ProviderDetailPage() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isAnthropicCompatible
|
||||
? "Anthropic Compatible Details"
|
||||
: "OpenAI Compatible Details"}
|
||||
? t("anthropicCompatibleDetails")
|
||||
: t("openaiCompatibleDetails")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{isAnthropicCompatible
|
||||
? "Messages API"
|
||||
? t("messagesApi")
|
||||
: providerNode.apiType === "responses"
|
||||
? "Responses API"
|
||||
: "Chat Completions"}{" "}
|
||||
? t("responsesApi")
|
||||
: t("chatCompletions")}{" "}
|
||||
· {(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||
{isAnthropicCompatible
|
||||
? "messages"
|
||||
@@ -836,7 +839,34 @@ export default function ProviderDetailPage() {
|
||||
{/* Connections */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">Connections</h2>
|
||||
{/* Provider-level proxy indicator/button */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setProxyTarget({
|
||||
level: "provider",
|
||||
id: providerId,
|
||||
label: providerInfo?.name || providerId,
|
||||
})
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
proxyConfig?.providers?.[providerId]
|
||||
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={
|
||||
proxyConfig?.providers?.[providerId]
|
||||
? `Provider proxy: ${proxyConfig.providers[providerId].host || "configured"}`
|
||||
: "Configure proxy for all connections of this provider"
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">vpn_lock</span>
|
||||
{proxyConfig?.providers?.[providerId]
|
||||
? proxyConfig.providers[providerId].host || "Provider Proxy"
|
||||
: "Provider Proxy"}
|
||||
</button>
|
||||
</div>
|
||||
{!isCompatible && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -896,7 +926,29 @@ export default function ProviderDetailPage() {
|
||||
label: conn.name || conn.email || conn.id,
|
||||
})
|
||||
}
|
||||
hasProxy={!!proxyConfig?.keys?.[conn.id]}
|
||||
hasProxy={
|
||||
!!(
|
||||
proxyConfig?.keys?.[conn.id] ||
|
||||
proxyConfig?.providers?.[providerId] ||
|
||||
proxyConfig?.global
|
||||
)
|
||||
}
|
||||
proxySource={
|
||||
proxyConfig?.keys?.[conn.id]
|
||||
? "key"
|
||||
: proxyConfig?.providers?.[providerId]
|
||||
? "provider"
|
||||
: proxyConfig?.global
|
||||
? "global"
|
||||
: null
|
||||
}
|
||||
proxyHost={
|
||||
(
|
||||
proxyConfig?.keys?.[conn.id] ||
|
||||
proxyConfig?.providers?.[providerId] ||
|
||||
proxyConfig?.global
|
||||
)?.host || null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1290,7 +1342,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
|
||||
const fetchCustomModels = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${providerId}`);
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCustomModels(data.models || []);
|
||||
@@ -1334,7 +1386,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
|
||||
const handleRemove = async (modelId) => {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/provider-models?provider=${providerId}&model=${encodeURIComponent(modelId)}`,
|
||||
`/api/provider-models?provider=${encodeURIComponent(providerId)}&model=${encodeURIComponent(modelId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
@@ -1461,6 +1513,7 @@ function CompatibleModelsSection({
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
@@ -1490,7 +1543,7 @@ function CompatibleModelsSection({
|
||||
const modelId = newModel.trim();
|
||||
const resolvedAlias = resolveAlias(modelId);
|
||||
if (!resolvedAlias) {
|
||||
alert(
|
||||
notify.error(
|
||||
"All suggested aliases already exist. Please choose a different model or remove conflicting aliases."
|
||||
);
|
||||
return;
|
||||
@@ -1498,10 +1551,37 @@ function CompatibleModelsSection({
|
||||
|
||||
setAdding(true);
|
||||
try {
|
||||
// Save to customModels DB FIRST - only create alias if this succeeds
|
||||
const customModelRes = await fetch("/api/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerStorageAlias,
|
||||
modelId,
|
||||
modelName: modelId,
|
||||
source: "manual",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!customModelRes.ok) {
|
||||
let errorData: { error?: { message?: string } } = {};
|
||||
try {
|
||||
errorData = await customModelRes.json();
|
||||
} catch (jsonError) {
|
||||
console.error("Failed to parse error response from custom model API:", jsonError);
|
||||
}
|
||||
throw new Error(errorData.error?.message || "Failed to save custom model");
|
||||
}
|
||||
|
||||
// Only create alias after customModel is saved successfully
|
||||
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
|
||||
setNewModel("");
|
||||
notify.success(`Model ${modelId} added successfully`);
|
||||
} catch (error) {
|
||||
console.log("Error adding model:", error);
|
||||
console.error("Error adding model:", error);
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : "Failed to add model. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
@@ -1528,12 +1608,32 @@ function CompatibleModelsSection({
|
||||
if (!modelId) return false;
|
||||
const resolvedAlias = resolveAlias(modelId);
|
||||
if (!resolvedAlias) return false;
|
||||
|
||||
// Save to customModels DB FIRST - only create alias if this succeeds
|
||||
const customModelRes = await fetch("/api/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerStorageAlias,
|
||||
modelId,
|
||||
modelName: model.name || modelId,
|
||||
source: "imported",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!customModelRes.ok) {
|
||||
notify.error("Failed to save imported model to custom database");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only create alias after customModel is saved successfully
|
||||
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error importing models:", error);
|
||||
console.error("Error importing models:", error);
|
||||
notify.error("Failed to import models. Please try again.");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -1541,6 +1641,28 @@ function CompatibleModelsSection({
|
||||
|
||||
const canImport = connections.some((conn) => conn.isActive !== false);
|
||||
|
||||
// Handle delete: remove from both alias and customModels DB
|
||||
const handleDeleteModel = async (modelId: string, alias: string) => {
|
||||
try {
|
||||
// Remove from customModels DB
|
||||
const res = await fetch(
|
||||
`/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&model=${encodeURIComponent(modelId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to remove model from database");
|
||||
}
|
||||
// Also delete the alias
|
||||
await onDeleteAlias(alias);
|
||||
notify.success("Model removed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting model:", error);
|
||||
notify.error(
|
||||
error instanceof Error ? error.message : "Failed to delete model. Please try again."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
@@ -1593,7 +1715,7 @@ function CompatibleModelsSection({
|
||||
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||
onDeleteAlias={() => handleDeleteModel(modelId, alias)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1820,6 +1942,8 @@ function ConnectionRow({
|
||||
onReauth,
|
||||
onProxy,
|
||||
hasProxy,
|
||||
proxySource,
|
||||
proxyHost,
|
||||
}) {
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
@@ -1921,18 +2045,33 @@ function ConnectionRow({
|
||||
<span className="material-symbols-outlined text-[13px]">shield</span>
|
||||
{rateLimitEnabled ? "Protected" : "Unprotected"}
|
||||
</button>
|
||||
{hasProxy && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/15 text-primary"
|
||||
title="Proxy configured"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">vpn_lock</span>
|
||||
Proxy
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{hasProxy &&
|
||||
(() => {
|
||||
const colorClass =
|
||||
proxySource === "global"
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: proxySource === "provider"
|
||||
? "bg-amber-500/15 text-amber-500"
|
||||
: "bg-blue-500/15 text-blue-500";
|
||||
const label =
|
||||
proxySource === "global"
|
||||
? "Global"
|
||||
: proxySource === "provider"
|
||||
? "Provider"
|
||||
: "Key";
|
||||
return (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium ${colorClass}`}
|
||||
title={`Proxy (${label}): ${proxyHost || "configured"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">vpn_lock</span>
|
||||
{proxyHost || "Proxy"}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 */}
|
||||
@@ -98,7 +98,7 @@ export default function NewProviderPage() {
|
||||
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) => (
|
||||
@@ -152,11 +152,11 @@ export default function NewProviderPage() {
|
||||
<Input
|
||||
label="API Key"
|
||||
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,11 +164,9 @@ 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>
|
||||
)}
|
||||
@@ -186,8 +184,8 @@ export default function NewProviderPage() {
|
||||
<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 +199,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>
|
||||
|
||||
@@ -23,6 +23,7 @@ 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) {
|
||||
@@ -97,6 +98,8 @@ export default function ProvidersPage() {
|
||||
const [testingMode, setTestingMode] = useState(null);
|
||||
const [testResults, setTestResults] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
const t = useTranslations("providers");
|
||||
const tc = useTranslations("common");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
@@ -194,12 +197,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);
|
||||
}
|
||||
@@ -239,7 +242,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="OAuth" />
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelAvailabilityBadge />
|
||||
@@ -251,13 +255,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 +283,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="Free" />
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => handleBatchTest("free")}
|
||||
@@ -289,13 +294,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,7 +321,7 @@ 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{" "}
|
||||
{t("apiKeyProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-amber-500" title="API Key" />
|
||||
</h2>
|
||||
<button
|
||||
@@ -327,13 +332,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,7 +359,7 @@ 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{" "}
|
||||
{t("compatibleProviders")}{" "}
|
||||
<span className="size-2.5 rounded-full bg-orange-500" title="Compatible" />
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
@@ -367,16 +372,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 +390,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 +399,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,7 +445,7 @@ 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"
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
"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);
|
||||
|
||||
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 +50,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>
|
||||
@@ -53,6 +88,20 @@ export default function AppearanceTab() {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,8 @@ 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");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/combo-defaults")
|
||||
@@ -67,17 +70,15 @@ 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"
|
||||
@@ -176,8 +177,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 +192,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 +206,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
|
||||
@@ -230,7 +229,7 @@ export default function ComboDefaultsTab() {
|
||||
className="text-xs w-16"
|
||||
aria-label={`${provider} max retries`}
|
||||
/>
|
||||
<span className="text-[10px] text-text-muted">retries</span>
|
||||
<span className="text-[10px] text-text-muted">{t("retries")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="5000"
|
||||
@@ -249,7 +248,7 @@ export default function ComboDefaultsTab() {
|
||||
className="text-xs w-24"
|
||||
aria-label={`${provider} timeout ms`}
|
||||
/>
|
||||
<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"
|
||||
@@ -278,7 +277,7 @@ export default function ComboDefaultsTab() {
|
||||
onClick={addProviderOverride}
|
||||
disabled={!newOverrideProvider.trim()}
|
||||
>
|
||||
Add
|
||||
{tc("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,7 +285,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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const ALL_COLUMNS = [
|
||||
{ key: "timestamp", label: "Time" },
|
||||
@@ -23,6 +24,7 @@ export default function ComplianceTab() {
|
||||
details: true,
|
||||
});
|
||||
const notify = useNotificationStore();
|
||||
const t = useTranslations("settings");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/compliance/audit-log?limit=100")
|
||||
@@ -33,7 +35,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
|
||||
|
||||
@@ -92,7 +94,7 @@ export default function ComplianceTab() {
|
||||
<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} />
|
||||
</div>
|
||||
@@ -100,7 +102,7 @@ export default function ComplianceTab() {
|
||||
<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 },
|
||||
@@ -118,7 +120,7 @@ export default function ComplianceTab() {
|
||||
loading={loading}
|
||||
maxHeight="400px"
|
||||
emptyIcon="📋"
|
||||
emptyMessage="No audit events found"
|
||||
emptyMessage={t("noAuditEvents")}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
label={t("modelName")}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Providers (comma-separated, in priority order)"
|
||||
label={t("providersCommaSeparated")}
|
||||
placeholder="anthropic, openai, gemini"
|
||||
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">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const MODES = [
|
||||
{ value: "disabled", label: "Disabled", icon: "block" },
|
||||
@@ -11,10 +12,17 @@ const MODES = [
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -75,8 +84,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,10 +102,16 @@ 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"}`}>
|
||||
<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>
|
||||
</button>
|
||||
@@ -109,7 +124,7 @@ export default function IPFilterSection() {
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Add IP Address"
|
||||
label={t("addIpAddress")}
|
||||
placeholder="192.168.1.0/24 or 10.0.*.*"
|
||||
value={newIP}
|
||||
onChange={(e) => setNewIP(e.target.value)}
|
||||
@@ -120,16 +135,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>
|
||||
@@ -148,7 +169,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>
|
||||
@@ -171,7 +195,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>
|
||||
@@ -201,7 +228,10 @@ export default function IPFilterSection() {
|
||||
<span className="text-xs text-text-muted tabular-nums">
|
||||
{Math.ceil(ban.remainingMs / 60000)}m left
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// ─── State colors and labels ──────────────────────────────────────────────
|
||||
const STATE_STYLES = {
|
||||
@@ -46,6 +47,8 @@ function formatMs(ms) {
|
||||
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);
|
||||
@@ -72,12 +75,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 +89,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,7 +108,7 @@ 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 }) => (
|
||||
@@ -148,6 +148,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 +171,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,24 +185,21 @@ 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">
|
||||
{[
|
||||
@@ -233,7 +232,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
|
||||
@@ -250,7 +249,7 @@ function RateLimitCard({ rateLimitStatus, defaults, onSaveDefaults, saving }) {
|
||||
))}
|
||||
</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 +260,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,7 +270,7 @@ 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">
|
||||
@@ -286,17 +286,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) => {
|
||||
@@ -343,6 +340,7 @@ function PoliciesCard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [unlocking, setUnlocking] = useState(null);
|
||||
const notify = useNotificationStore();
|
||||
const t = useTranslations("settings");
|
||||
|
||||
const fetchPolicies = useCallback(async () => {
|
||||
try {
|
||||
@@ -376,10 +374,10 @@ function PoliciesCard() {
|
||||
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);
|
||||
}
|
||||
@@ -394,7 +392,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 +406,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 +421,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 +429,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")
|
||||
@@ -479,7 +475,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;
|
||||
@@ -506,7 +502,7 @@ function PoliciesCard() {
|
||||
disabled={unlocking === identifier}
|
||||
className="text-xs"
|
||||
>
|
||||
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
|
||||
{unlocking === identifier ? t("unlocking") : t("forceUnlock")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -527,6 +523,7 @@ 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 {
|
||||
@@ -601,7 +598,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 +611,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>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Input, Toggle, Button } from "@/shared/components";
|
||||
import FallbackChainsEditor from "./FallbackChainsEditor";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const STRATEGIES = [
|
||||
{
|
||||
@@ -34,6 +35,7 @@ export default function RoutingTab() {
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [newPattern, setNewPattern] = useState("");
|
||||
const [newTarget, setNewTarget] = useState("");
|
||||
const t = useTranslations("settings");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
@@ -86,7 +88,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" }}>
|
||||
@@ -123,8 +125,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"
|
||||
@@ -163,10 +165,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,7 +198,7 @@ export default function RoutingTab() {
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Pattern"
|
||||
label={t("pattern")}
|
||||
placeholder="claude-sonnet-*"
|
||||
value={newPattern}
|
||||
onChange={(e) => setNewPattern(e.target.value)}
|
||||
@@ -206,14 +206,14 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Target Model"
|
||||
label={t("targetModel")}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
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>
|
||||
|
||||
@@ -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,13 +181,13 @@ 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="font-medium">{t("requireAuthModels")}</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">
|
||||
@@ -207,7 +207,7 @@ 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="font-medium">{t("blockedProviders")}</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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Badge } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SystemStorageTab() {
|
||||
const [backups, setBackups] = useState([]);
|
||||
@@ -18,6 +19,8 @@ export default function SystemStorageTab() {
|
||||
const [confirmImport, setConfirmImport] = useState(false);
|
||||
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
const [storageHealth, setStorageHealth] = useState({
|
||||
driver: "sqlite",
|
||||
dbPath: "~/.omniroute/storage.sqlite",
|
||||
@@ -149,7 +152,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;
|
||||
}
|
||||
@@ -224,8 +227,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 +238,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 +259,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"
|
||||
@@ -288,13 +295,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,7 +323,7 @@ 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
|
||||
@@ -329,10 +336,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 +371,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"}
|
||||
: t("noBackupYet")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +388,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 +426,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 +436,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 +469,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,7 +479,7 @@ export default function SystemStorageTab() {
|
||||
>
|
||||
folder_off
|
||||
</span>
|
||||
No backups available yet. Backups will be created automatically when data changes.
|
||||
{t("noBackupsYet")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -490,7 +494,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) => (
|
||||
@@ -531,7 +535,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 +543,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 +565,7 @@ export default function SystemStorageTab() {
|
||||
>
|
||||
restore
|
||||
</span>
|
||||
Restore
|
||||
{t("restore")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Select } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
@@ -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,7 +123,9 @@ 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" : ""}`}>
|
||||
<p
|
||||
className={`text-sm font-medium ${config.mode === m.value ? "text-violet-400" : ""}`}
|
||||
>
|
||||
{m.label}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">{m.desc}</p>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>Input:</strong> {t("inputTokenDesc")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Output:</strong> Completion/response tokens
|
||||
<strong>Output:</strong> {t("outputTokenDesc")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cached:</strong> Cached input tokens (typically 50% of input rate)
|
||||
<strong>Cached:</strong> {t("cachedTokenDesc")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Reasoning:</strong> Special reasoning/thinking tokens (fallback to output
|
||||
rate)
|
||||
<strong>Reasoning:</strong> {t("reasoningTokenDesc")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cache Creation:</strong> Tokens used to create cache entries (fallback to
|
||||
input rate)
|
||||
<strong>Cache Creation:</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)
|
||||
@@ -160,7 +153,7 @@ export default function PricingSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text-muted">No pricing data available</div>
|
||||
<div className="text-text-muted">{t("noPricing")}</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,6 +12,8 @@ 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);
|
||||
@@ -55,23 +59,27 @@ export default function LiveMonitorMode() {
|
||||
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={`${avgLatency}ms`} color="purple" />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
@@ -87,7 +95,7 @@ export default function LiveMonitorMode() {
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
className="text-sm text-text-main hover:text-primary transition-colors"
|
||||
>
|
||||
{autoRefresh ? "Live — Auto-refreshing" : "Paused"}
|
||||
{autoRefresh ? t("liveAutoRefreshing") : t("paused")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -95,7 +103,7 @@ export default function LiveMonitorMode() {
|
||||
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
|
||||
{tc("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -103,53 +111,48 @@ 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...
|
||||
{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">
|
||||
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>
|
||||
@@ -192,11 +195,11 @@ export default function LiveMonitorMode() {
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
@@ -8,6 +10,8 @@ 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("");
|
||||
@@ -114,12 +118,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 +128,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 +145,7 @@ export default function PlaygroundMode() {
|
||||
/>
|
||||
{detectedFormat && (
|
||||
<Badge variant="primary" size="sm" icon="auto_awesome">
|
||||
Auto
|
||||
{t("auto")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -155,7 +155,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 +163,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 +187,7 @@ export default function PlaygroundMode() {
|
||||
disabled={!inputContent.trim() || translating}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Translate
|
||||
{t("translateAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,7 +201,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 +217,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 +229,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 +250,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 +265,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 +276,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,15 +303,15 @@ 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) => (
|
||||
@@ -339,11 +339,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>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
@@ -26,6 +28,7 @@ const SCENARIOS = [
|
||||
];
|
||||
|
||||
export default function TestBenchMode() {
|
||||
const t = useTranslations("translator");
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
@@ -147,7 +150,7 @@ export default function TestBenchMode() {
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
|
||||
<p className="font-medium text-text-main mb-0.5">{t("compatibilityTester")}</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
|
||||
@@ -232,7 +235,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"
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* BudgetTab — Batch C
|
||||
*
|
||||
@@ -34,6 +36,7 @@ function ProgressBar({ value, max, warningAt = 0.8 }) {
|
||||
}
|
||||
|
||||
export default function BudgetTab() {
|
||||
const t = useTranslations("usage");
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [selectedKey, setSelectedKey] = useState(null);
|
||||
const [budget, setBudget] = useState(null);
|
||||
@@ -142,11 +145,11 @@ export default function BudgetTab() {
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Budget Management</h3>
|
||||
<h3 className="text-lg font-semibold">{t("budgetManagement")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-text-muted mb-1 block">API Key</label>
|
||||
<label className="text-sm text-text-muted mb-1 block">{t("apiKey")}</label>
|
||||
<select
|
||||
value={selectedKey || ""}
|
||||
onChange={(e) => setSelectedKey(e.target.value)}
|
||||
@@ -170,7 +173,7 @@ export default function BudgetTab() {
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
|
||||
<p className="text-sm text-text-muted mb-2">This Month</p>
|
||||
<p className="text-sm text-text-muted mb-2">{t("thisMonth")}</p>
|
||||
<p className="text-2xl font-bold text-text-main">${monthlyCost.toFixed(2)}</p>
|
||||
{monthlyLimit > 0 && (
|
||||
<ProgressBar value={monthlyCost} max={monthlyLimit} warningAt={warnPct} />
|
||||
@@ -180,7 +183,7 @@ export default function BudgetTab() {
|
||||
|
||||
{/* Budget Form */}
|
||||
<div className="border-t border-border/30 pt-4">
|
||||
<p className="text-sm font-medium mb-3">Set Limits</p>
|
||||
<p className="text-sm font-medium mb-3">{t("setLimits")}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<Input
|
||||
label="Daily Limit (USD)"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function BudgetTelemetryCards() {
|
||||
const t = useTranslations("usage");
|
||||
const [telemetry, setTelemetry] = useState(null);
|
||||
const [cache, setCache] = useState(null);
|
||||
const [policies, setPolicies] = useState(null);
|
||||
@@ -45,12 +48,12 @@ export default function BudgetTelemetryCards() {
|
||||
<span className="font-mono">{fmt(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>
|
||||
|
||||
@@ -63,17 +66,17 @@ export default function BudgetTelemetryCards() {
|
||||
{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>
|
||||
@@ -93,11 +96,11 @@ export default function BudgetTelemetryCards() {
|
||||
{policies ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Circuit Breakers</span>
|
||||
<span className="text-text-muted">{t("circuitBreakers")}</span>
|
||||
<span className="font-mono">{policies.circuitBreakers?.length ?? 0} active</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">Locked IPs</span>
|
||||
<span className="text-text-muted">{t("lockedIPs")}</span>
|
||||
<span className="font-mono">{policies.lockedIdentifiers?.length ?? 0}</span>
|
||||
</div>
|
||||
{policies.circuitBreakers?.some((cb) => cb.state === "OPEN") && (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* EvalsTab — Batch F
|
||||
*
|
||||
@@ -49,6 +51,7 @@ const STRATEGIES = [
|
||||
];
|
||||
|
||||
export default function EvalsTab() {
|
||||
const t = useTranslations("usage");
|
||||
const [suites, setSuites] = useState([]);
|
||||
const [apiKey, setApiKey] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -255,7 +258,7 @@ export default function EvalsTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* How It Works — Collapsible */}
|
||||
{/* {t("howItWorks")} — Collapsible */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowHowItWorks(!showHowItWorks)}
|
||||
@@ -289,7 +292,7 @@ export default function EvalsTab() {
|
||||
<div className="w-10 h-10 rounded-full bg-violet-500/20 flex items-center justify-center mb-3">
|
||||
<span className="text-lg font-bold text-violet-400">1</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-text-main mb-1">Define</h4>
|
||||
<h4 className="text-sm font-semibold text-text-main mb-1">{t("define")}</h4>
|
||||
<p className="text-xs text-text-muted">
|
||||
Create test cases with input prompts and expected output criteria using strategies
|
||||
like contains, regex, or exact match.
|
||||
@@ -299,7 +302,7 @@ export default function EvalsTab() {
|
||||
<div className="w-10 h-10 rounded-full bg-sky-500/20 flex items-center justify-center mb-3">
|
||||
<span className="text-lg font-bold text-sky-400">2</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-text-main mb-1">Run</h4>
|
||||
<h4 className="text-sm font-semibold text-text-main mb-1">{t("run")}</h4>
|
||||
<p className="text-xs text-text-muted">
|
||||
Execute test cases against your LLM endpoints through OmniRoute. Each case is sent
|
||||
as a real API request.
|
||||
@@ -309,7 +312,7 @@ export default function EvalsTab() {
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center mb-3">
|
||||
<span className="text-lg font-bold text-emerald-400">3</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-text-main mb-1">Evaluate</h4>
|
||||
<h4 className="text-sm font-semibold text-text-main mb-1">{t("evaluate")}</h4>
|
||||
<p className="text-xs text-text-muted">
|
||||
Responses are compared against expected criteria. See pass/fail for each case with
|
||||
latency metrics and detailed feedback.
|
||||
@@ -369,7 +372,7 @@ export default function EvalsTab() {
|
||||
<span className="material-symbols-outlined text-[20px]">science</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Evaluation Suites</h3>
|
||||
<h3 className="text-lg font-semibold">{t("evalSuites")}</h3>
|
||||
<p className="text-xs text-text-muted">
|
||||
Click a suite to view test cases, then run to evaluate your LLM endpoints
|
||||
</p>
|
||||
@@ -663,7 +666,7 @@ function HeroSection() {
|
||||
<span className="material-symbols-outlined text-[28px]">science</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-text-main mb-1">Model Evaluations</h2>
|
||||
<h2 className="text-xl font-bold text-text-main mb-1">{t("modelEvals")}</h2>
|
||||
<p className="text-sm text-text-muted leading-relaxed max-w-2xl">
|
||||
Test and validate your LLM endpoints by running predefined evaluation suites. Each
|
||||
suite contains test cases that send real prompts through OmniRoute and compare
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { parseQuotaData, calculatePercentage, normalizePlanTier } from "./utils";
|
||||
@@ -23,9 +25,11 @@ const PROVIDER_CONFIG = {
|
||||
const TIER_FILTERS = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "enterprise", label: "Enterprise" },
|
||||
{ key: "team", label: "Team" },
|
||||
{ key: "business", label: "Business" },
|
||||
{ key: "ultra", label: "Ultra" },
|
||||
{ key: "pro", label: "Pro" },
|
||||
{ key: "plus", label: "Plus" },
|
||||
{ key: "free", label: "Free" },
|
||||
{ key: "unknown", label: "Unknown" },
|
||||
];
|
||||
@@ -79,6 +83,7 @@ function formatCountdown(resetAt) {
|
||||
}
|
||||
|
||||
export default function ProviderLimits() {
|
||||
const t = useTranslations("usage");
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
const [loading, setLoading] = useState({});
|
||||
@@ -249,6 +254,7 @@ export default function ProviderLimits() {
|
||||
const counts = {
|
||||
all: sortedConnections.length,
|
||||
enterprise: 0,
|
||||
team: 0,
|
||||
business: 0,
|
||||
ultra: 0,
|
||||
pro: 0,
|
||||
@@ -283,7 +289,7 @@ export default function ProviderLimits() {
|
||||
<Card padding="lg">
|
||||
<div className="text-center py-12">
|
||||
<span className="material-symbols-outlined text-[64px] opacity-15">cloud_off</span>
|
||||
<h3 className="mt-4 text-lg font-semibold text-text-main">No Providers Connected</h3>
|
||||
<h3 className="mt-4 text-lg font-semibold text-text-main">{t("noProviders")}</h3>
|
||||
<p className="mt-2 text-sm text-text-muted max-w-[400px] mx-auto">
|
||||
Connect to providers with OAuth to track your API quota limits and usage.
|
||||
</p>
|
||||
@@ -297,7 +303,7 @@ export default function ProviderLimits() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-text-main m-0">Provider Limits</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main m-0">{t("providerLimits")}</h2>
|
||||
<span className="text-[13px] text-text-muted">
|
||||
{visibleConnections.length} account{visibleConnections.length !== 1 ? "s" : ""}
|
||||
{visibleConnections.length !== sortedConnections.length
|
||||
@@ -370,10 +376,10 @@ export default function ProviderLimits() {
|
||||
className="items-center px-4 py-2.5 border-b border-white/[0.06] text-[11px] font-semibold uppercase tracking-wider text-text-muted"
|
||||
style={{ display: "grid", gridTemplateColumns: "280px 1fr 100px 48px" }}
|
||||
>
|
||||
<div>Account</div>
|
||||
<div>Model Quotas</div>
|
||||
<div className="text-center">Last Used</div>
|
||||
<div className="text-center">Actions</div>
|
||||
<div>{t("account")}</div>
|
||||
<div>{t("modelQuotas")}</div>
|
||||
<div className="text-center">{t("lastUsed")}</div>
|
||||
<div className="text-center">{t("actions")}</div>
|
||||
</div>
|
||||
|
||||
{visibleConnections.map((conn, idx) => {
|
||||
@@ -488,7 +494,7 @@ export default function ProviderLimits() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-xs text-text-muted italic">No quota data</div>
|
||||
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ export function parseQuotaData(provider, data) {
|
||||
|
||||
/**
|
||||
* Normalize provider-specific plan labels into a shared tier taxonomy.
|
||||
* Supported tiers: enterprise, business, ultra, pro, free, unknown.
|
||||
* Supported tiers: enterprise, business, team, ultra, pro, free, unknown.
|
||||
*/
|
||||
export function normalizePlanTier(plan) {
|
||||
const raw = typeof plan === "string" ? plan.trim() : "";
|
||||
@@ -213,10 +213,15 @@ export function normalizePlanTier(plan) {
|
||||
const upper = raw.toUpperCase();
|
||||
|
||||
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
|
||||
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 6, raw };
|
||||
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("BUSINESS") || upper.includes("TEAM") || upper.includes("STANDARD")) {
|
||||
// Team plan (e.g., ChatGPT Team, GitHub Team)
|
||||
if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) {
|
||||
return { key: "team", label: "Team", variant: "info", rank: 6, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) {
|
||||
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
|
||||
}
|
||||
|
||||
@@ -224,15 +229,14 @@ export function normalizePlanTier(plan) {
|
||||
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (
|
||||
upper.includes("PRO") ||
|
||||
upper.includes("PLUS") ||
|
||||
upper.includes("PREMIUM") ||
|
||||
upper.includes("PAID")
|
||||
) {
|
||||
if (upper.includes("PRO") || upper.includes("PREMIUM")) {
|
||||
return { key: "pro", label: "Pro", variant: "primary", rank: 3, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("PLUS") || upper.includes("PAID")) {
|
||||
return { key: "plus", label: "Plus", variant: "secondary", rank: 2, raw };
|
||||
}
|
||||
|
||||
if (
|
||||
upper.includes("FREE") ||
|
||||
upper.includes("INDIVIDUAL") ||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function RateLimitStatus() {
|
||||
const t = useTranslations("usage");
|
||||
const [data, setData] = useState({ lockouts: [], cacheStats: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -31,7 +34,7 @@ export default function RateLimitStatus() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Model Lockouts */}
|
||||
{/* {t("modelLockouts")} */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-orange-500/10 text-orange-500">
|
||||
@@ -55,7 +58,7 @@ export default function RateLimitStatus() {
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 block opacity-40">
|
||||
lock_open
|
||||
</span>
|
||||
<p className="text-sm">No models currently locked</p>
|
||||
<p className="text-sm">{t("noLockouts")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function SessionsTab() {
|
||||
const t = useTranslations("usage");
|
||||
const [data, setData] = useState({ count: 0, sessions: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -11,7 +14,8 @@ export default function SessionsTab() {
|
||||
try {
|
||||
const res = await fetch("/api/sessions");
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch {} finally {
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -37,15 +41,15 @@ export default function SessionsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Active Sessions</h3>
|
||||
<p className="text-sm text-text-muted">Tracked via request fingerprinting • Auto-refresh 5s</p>
|
||||
<h3 className="text-lg font-semibold">{t("activeSessions")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Tracked via request fingerprinting • Auto-refresh 5s
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/20">
|
||||
<span className="w-2 h-2 rounded-full bg-cyan-500 animate-pulse" />
|
||||
<span className="text-sm font-semibold tabular-nums text-cyan-400">
|
||||
{data.count}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-cyan-400">{data.count}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,23 +59,34 @@ export default function SessionsTab() {
|
||||
<span className="material-symbols-outlined text-[40px] mb-2 block opacity-40">
|
||||
fingerprint
|
||||
</span>
|
||||
<p className="text-sm">No active sessions</p>
|
||||
<p className="text-xs mt-1">Sessions appear as requests flow through the proxy</p>
|
||||
<p className="text-sm">{t("noSessions")}</p>
|
||||
<p className="text-xs mt-1">{t("sessionsHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Session</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Age</th>
|
||||
<th className="text-right py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Requests</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">Connection</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("session")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("age")}
|
||||
</th>
|
||||
<th className="text-right py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("requests")}
|
||||
</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("connection")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.sessions.map((s) => (
|
||||
<tr key={s.sessionId} className="border-b border-border/10 hover:bg-surface/20 transition-colors">
|
||||
<tr
|
||||
key={s.sessionId}
|
||||
className="border-b border-border/10 hover:bg-surface/20 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="font-mono text-xs px-2 py-1 rounded bg-surface/40 text-text-muted">
|
||||
{s.sessionId.slice(0, 12)}…
|
||||
@@ -83,7 +98,9 @@ export default function SessionsTab() {
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{s.connectionId ? (
|
||||
<span className="text-xs font-mono text-cyan-400">{s.connectionId.slice(0, 10)}</span>
|
||||
<span className="text-xs font-mono text-cyan-400">
|
||||
{s.connectionId.slice(0, 10)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted/40">—</span>
|
||||
)}
|
||||
|
||||
@@ -53,11 +53,23 @@ export async function GET() {
|
||||
try {
|
||||
const statuses = {};
|
||||
|
||||
// Run all runtime checks in parallel
|
||||
// Run all runtime checks in parallel with individual timeouts
|
||||
const RUNTIME_CHECK_TIMEOUT = 5000; // 5s per tool max
|
||||
await Promise.all(
|
||||
CLI_TOOL_IDS.map(async (toolId) => {
|
||||
try {
|
||||
const runtime = await getCliRuntimeStatus(toolId);
|
||||
const runtime = (await Promise.race([
|
||||
getCliRuntimeStatus(toolId),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timeout")), RUNTIME_CHECK_TIMEOUT)
|
||||
),
|
||||
])) as {
|
||||
installed: boolean;
|
||||
runnable: boolean;
|
||||
command?: string;
|
||||
commandPath?: string;
|
||||
reason?: string;
|
||||
};
|
||||
statuses[toolId] = {
|
||||
installed: runtime.installed,
|
||||
runnable: runtime.runnable,
|
||||
@@ -69,7 +81,7 @@ export async function GET() {
|
||||
statuses[toolId] = {
|
||||
installed: false,
|
||||
runnable: false,
|
||||
reason: error.message,
|
||||
reason: error.message || "Check failed",
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import {
|
||||
deleteApiKey,
|
||||
getApiKeyById,
|
||||
updateApiKeyPermissions,
|
||||
isCloudEnabled,
|
||||
} from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
|
||||
// GET /api/keys/[id] - Get single API key
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const key = await getApiKeyById(id);
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Mask the key value
|
||||
return NextResponse.json({
|
||||
...key,
|
||||
key: key.key ? key.key.slice(0, 8) + "****" + key.key.slice(-4) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching key:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/keys/[id] - Update API key permissions
|
||||
export async function PATCH(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { allowedModels } = body;
|
||||
|
||||
// Validate allowedModels is an array
|
||||
if (!Array.isArray(allowedModels)) {
|
||||
return NextResponse.json({ error: "allowedModels must be an array" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate each model ID is a string
|
||||
for (const model of allowedModels) {
|
||||
if (typeof model !== "string") {
|
||||
return NextResponse.json({ error: "Each model ID must be a string" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateApiKeyPermissions(id, allowedModels);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Permissions updated successfully",
|
||||
allowedModels,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating key permissions:", error);
|
||||
return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/keys/[id] - Delete API key
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
|
||||
@@ -2,10 +2,16 @@ import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
// GET /api/models/alias - Get all aliases
|
||||
export async function GET() {
|
||||
export async function GET(request) {
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const aliases = await getModelAliases();
|
||||
return NextResponse.json({ aliases });
|
||||
} catch (error) {
|
||||
@@ -17,6 +23,11 @@ export async function GET() {
|
||||
// PUT /api/models/alias - Set model alias
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { model, alias } = body;
|
||||
|
||||
@@ -37,6 +48,11 @@ export async function PUT(request) {
|
||||
// DELETE /api/models/alias?alias=xxx - Delete alias
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const alias = searchParams.get("alias");
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { startLocalServer } from "@/lib/oauth/utils/server";
|
||||
import { getProxyConfig } from "@/lib/localDb";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
// Use globalThis to persist callback server state across Next.js HMR reloads
|
||||
if (!globalThis.__codexCallbackState) {
|
||||
@@ -23,7 +25,10 @@ if (!globalThis.__codexCallbackState) {
|
||||
|
||||
// GET /api/oauth/[provider]/authorize - Generate auth URL
|
||||
// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow)
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ provider: string; action: string }> }) {
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ provider: string; action: string }> }
|
||||
) {
|
||||
try {
|
||||
const { provider, action } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -141,7 +146,10 @@ async function handleStartCallbackServer(provider: string, searchParams: URLSear
|
||||
|
||||
// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save
|
||||
// POST /api/oauth/[provider]/poll - Poll for token (device_code flow)
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ provider: string; action: string }> }) {
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ provider: string; action: string }> }
|
||||
) {
|
||||
try {
|
||||
const { provider, action } = await params;
|
||||
const body = await request.json();
|
||||
@@ -153,8 +161,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ pro
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state);
|
||||
// Resolve proxy for this provider (provider-level → global → direct)
|
||||
const proxyConfig = await getProxyConfig();
|
||||
const proxy = proxyConfig.providers?.[provider] || proxyConfig.global || null;
|
||||
|
||||
// Exchange code for tokens (through proxy if configured)
|
||||
const tokenData = await runWithProxyContext(proxy, () =>
|
||||
exchangeTokens(provider, code, redirectUri, codeVerifier, state)
|
||||
);
|
||||
|
||||
// Save to database
|
||||
const connection: any = await createProviderConnection({
|
||||
@@ -289,13 +303,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ pro
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const tokenData = await exchangeTokens(
|
||||
provider,
|
||||
params.code,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
params.state
|
||||
// Resolve proxy for this provider
|
||||
const proxyConfig = await getProxyConfig();
|
||||
const proxy = proxyConfig.providers?.[provider] || proxyConfig.global || null;
|
||||
|
||||
// Exchange code for tokens (through proxy if configured)
|
||||
const tokenData = await runWithProxyContext(proxy, () =>
|
||||
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
|
||||
);
|
||||
|
||||
// Save to database
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addCustomModel,
|
||||
removeCustomModel,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/provider-models?provider=<id>
|
||||
@@ -11,6 +12,14 @@ import {
|
||||
*/
|
||||
export async function GET(request) {
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_api_key" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider");
|
||||
|
||||
@@ -31,6 +40,14 @@ export async function GET(request) {
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_api_key" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { provider, modelId, modelName, source } = body;
|
||||
|
||||
@@ -56,6 +73,14 @@ export async function POST(request) {
|
||||
*/
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
// Require authentication for security
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{ error: { message: "Authentication required", type: "invalid_api_key" } },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider");
|
||||
const modelId = searchParams.get("model");
|
||||
|
||||
@@ -198,6 +198,14 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
kilocode: {
|
||||
url: "https://api.kilo.ai/api/openrouter/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || data.models || [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -220,8 +228,17 @@ export async function GET(request, { params }) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||
const response = await fetch(url, {
|
||||
|
||||
let modelsUrl = baseUrl.replace(/\/$/, "");
|
||||
if (modelsUrl.endsWith("/chat/completions")) {
|
||||
modelsUrl = modelsUrl.slice(0, -17) + "/models";
|
||||
} else if (modelsUrl.endsWith("/completions")) {
|
||||
modelsUrl = modelsUrl.slice(0, -12) + "/models";
|
||||
} else {
|
||||
modelsUrl = `${modelsUrl}/models`;
|
||||
}
|
||||
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
||||
import {
|
||||
getProviderConnectionById,
|
||||
updateProviderConnection,
|
||||
isCloudEnabled,
|
||||
resolveProxyForConnection,
|
||||
} from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { validateProviderApiKey } from "@/lib/providers/validation";
|
||||
@@ -8,6 +13,7 @@ import { getCliRuntimeStatus } from "@/shared/services/cliRuntime";
|
||||
import { getAccessToken } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { logProxyEvent } from "@/lib/proxyLogger";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
// OAuth provider test endpoints
|
||||
const OAUTH_TEST_CONFIG = {
|
||||
@@ -91,7 +97,12 @@ function toSafeMessage(value: any, fallback = "Unknown error"): string {
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
function makeDiagnosis(type: string, source: string, message: string | null, code: string | null = null) {
|
||||
function makeDiagnosis(
|
||||
type: string,
|
||||
source: string,
|
||||
message: string | null,
|
||||
code: string | null = null
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
source,
|
||||
@@ -100,7 +111,17 @@ function makeDiagnosis(type: string, source: string, message: string | null, cod
|
||||
};
|
||||
}
|
||||
|
||||
function classifyFailure({ error, statusCode = null, refreshFailed = false, unsupported = false }: { error: string; statusCode?: number | null; refreshFailed?: boolean; unsupported?: boolean }) {
|
||||
function classifyFailure({
|
||||
error,
|
||||
statusCode = null,
|
||||
refreshFailed = false,
|
||||
unsupported = false,
|
||||
}: {
|
||||
error: string;
|
||||
statusCode?: number | null;
|
||||
refreshFailed?: boolean;
|
||||
unsupported?: boolean;
|
||||
}) {
|
||||
const message = toSafeMessage(error, "Connection test failed");
|
||||
const normalized = message.toLowerCase();
|
||||
const numericStatus = Number.isFinite(statusCode) ? Number(statusCode) : null;
|
||||
@@ -510,6 +531,14 @@ export async function testSingleConnection(connectionId: string) {
|
||||
return { valid: false, error: "Connection not found", diagnosis: null, latencyMs: 0 };
|
||||
}
|
||||
|
||||
// Resolve proxy for this connection (key → combo → provider → global → direct)
|
||||
let proxyInfo: any = null;
|
||||
try {
|
||||
proxyInfo = await resolveProxyForConnection(connectionId);
|
||||
} catch (proxyErr: any) {
|
||||
console.log(`[ConnectionTest] Failed to resolve proxy for ${connectionId}:`, proxyErr?.message);
|
||||
}
|
||||
|
||||
let result;
|
||||
const startTime = Date.now();
|
||||
const runtime = await getProviderRuntimeStatus(connection.provider);
|
||||
@@ -522,9 +551,13 @@ export async function testSingleConnection(connectionId: string) {
|
||||
diagnosis: (runtime as any).diagnosis,
|
||||
};
|
||||
} else if (connection.authType === "apikey") {
|
||||
result = await testApiKeyConnection(connection);
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
testApiKeyConnection(connection)
|
||||
);
|
||||
} else {
|
||||
result = await testOAuthConnection(connection);
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
testOAuthConnection(connection)
|
||||
);
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
@@ -591,9 +624,9 @@ export async function testSingleConnection(connectionId: string) {
|
||||
try {
|
||||
logProxyEvent({
|
||||
status: result.valid ? "success" : "error",
|
||||
proxy: null,
|
||||
level: "provider-test",
|
||||
levelId: null,
|
||||
proxy: proxyInfo?.proxy || null,
|
||||
level: proxyInfo?.level || "provider-test",
|
||||
levelId: proxyInfo?.levelId || null,
|
||||
provider: connection.provider,
|
||||
targetUrl: `${connection.provider}/connection-test`,
|
||||
latencyMs,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user