Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -96,3 +96,4 @@ security-analysis/
|
||||
.agent/workflows/deploy.md
|
||||
clipr/
|
||||
app.log
|
||||
*.tgz
|
||||
|
||||
@@ -7,6 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
@@ -546,6 +631,15 @@ 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
|
||||
|
||||
@@ -265,14 +265,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
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" },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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()}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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][]) {
|
||||
|
||||
@@ -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
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.7",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"open-sse"
|
||||
@@ -5339,6 +5339,7 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.7",
|
||||
"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": {
|
||||
|
||||
@@ -631,8 +631,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}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"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";
|
||||
@@ -1290,7 +1291,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 +1335,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 +1462,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 +1492,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 +1500,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 +1557,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 +1590,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 +1664,7 @@ function CompatibleModelsSection({
|
||||
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||
onDeleteAlias={() => handleDeleteModel(modelId, alias)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ const TIER_FILTERS = [
|
||||
{ key: "business", label: "Business" },
|
||||
{ key: "ultra", label: "Ultra" },
|
||||
{ key: "pro", label: "Pro" },
|
||||
{ key: "plus", label: "Plus" },
|
||||
{ key: "free", label: "Free" },
|
||||
{ key: "unknown", label: "Unknown" },
|
||||
];
|
||||
|
||||
@@ -229,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") ||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
|
||||
import { parseSpeechModel } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -41,6 +42,10 @@ export async function POST(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const { provider } = parseSpeechModel(body.model);
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
|
||||
import { parseTranscriptionModel } from "@omniroute/open-sse/config/audioRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -43,6 +44,10 @@ export async function POST(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, model as string);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const { provider } = parseTranscriptionModel(model);
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -78,6 +79,10 @@ export async function POST(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing input");
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// Parse model to get provider
|
||||
const { provider } = parseEmbeddingModel(body.model);
|
||||
if (!provider) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -75,6 +76,10 @@ export async function POST(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid prompt: expected a non-empty string");
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// Parse model to get provider
|
||||
const { provider } = parseImageModel(body.model);
|
||||
if (!provider) {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderConnections, getCombos, getAllCustomModels, getSettings } from "@/lib/localDb";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
getProviderConnections,
|
||||
getCombos,
|
||||
getAllCustomModels,
|
||||
getSettings,
|
||||
getProviderNodes,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts";
|
||||
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
@@ -107,35 +111,7 @@ export async function GET(request: Request) {
|
||||
settings = await getSettings();
|
||||
} catch {}
|
||||
if (settings.requireAuthForModels === true) {
|
||||
// Check authentication: API key OR dashboard session (JWT cookie)
|
||||
// Supports dual auth: Bearer token for external clients, cookie for dashboard.
|
||||
let isAuthenticated = false;
|
||||
|
||||
// 1. Check API key (for external clients)
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey && (await isValidApiKey(apiKey))) {
|
||||
isAuthenticated = true;
|
||||
}
|
||||
|
||||
// 2. Check JWT cookie (for dashboard session)
|
||||
// The auth_token cookie has sameSite:lax + httpOnly, which already
|
||||
// prevents cross-origin abuse — no additional origin check needed.
|
||||
// Same pattern as shared/utils/apiAuth.ts verifyAuth().
|
||||
if (!isAuthenticated && process.env.JWT_SECRET) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("auth_token")?.value;
|
||||
if (token) {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
await jwtVerify(token, secret);
|
||||
isAuthenticated = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid/expired token or cookies not available — not authenticated
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
@@ -169,6 +145,26 @@ export async function GET(request: Request) {
|
||||
console.log("Could not fetch providers, showing only combos/custom models");
|
||||
}
|
||||
|
||||
// Get provider nodes (for compatible providers with custom prefixes)
|
||||
let providerNodes = [];
|
||||
try {
|
||||
providerNodes = await getProviderNodes();
|
||||
} catch (e) {
|
||||
console.log("Could not fetch provider nodes");
|
||||
}
|
||||
|
||||
// Build map of provider node ID to prefix and type for compatible providers
|
||||
const providerIdToPrefix: Record<string, string> = {};
|
||||
const nodeIdToProviderType: Record<string, string> = {};
|
||||
for (const node of providerNodes) {
|
||||
if (node.prefix) {
|
||||
providerIdToPrefix[node.id] = node.prefix;
|
||||
}
|
||||
if (node.type) {
|
||||
nodeIdToProviderType[node.id] = node.type;
|
||||
}
|
||||
}
|
||||
|
||||
// Get combos
|
||||
let combos = [];
|
||||
try {
|
||||
@@ -318,14 +314,19 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const customModelsMap: Record<string, any[]> = await getAllCustomModels();
|
||||
for (const [providerId, providerCustomModels] of Object.entries(customModelsMap)) {
|
||||
const alias = providerIdToAlias[providerId] || providerId;
|
||||
// For compatible providers, use the prefix from provider nodes
|
||||
const prefix = providerIdToPrefix[providerId];
|
||||
const alias = prefix || providerIdToAlias[providerId] || providerId;
|
||||
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId;
|
||||
// Only include if provider is active — check alias, canonical ID, or raw providerId
|
||||
// (raw check needed for OpenAI-compatible providers whose ID isn't in the alias map)
|
||||
|
||||
// Only include if provider is active — check alias, canonical ID, raw providerId,
|
||||
// or the parent provider type (for compatible providers whose node ID is a UUID)
|
||||
const parentProviderType = nodeIdToProviderType[providerId];
|
||||
if (
|
||||
!activeAliases.has(alias) &&
|
||||
!activeAliases.has(canonicalProviderId) &&
|
||||
!activeAliases.has(providerId)
|
||||
!activeAliases.has(providerId) &&
|
||||
!(parentProviderType && activeAliases.has(parentProviderType))
|
||||
)
|
||||
continue;
|
||||
|
||||
@@ -345,7 +346,8 @@ export async function GET(request: Request) {
|
||||
custom: true,
|
||||
});
|
||||
|
||||
if (canonicalProviderId !== alias) {
|
||||
// Only add provider-prefixed version if different from alias
|
||||
if (canonicalProviderId !== alias && !prefix) {
|
||||
const providerPrefixedId = `${canonicalProviderId}/${model.id}`;
|
||||
if (models.some((m) => m.id === providerPrefixedId)) continue;
|
||||
models.push({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
|
||||
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -38,6 +39,11 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
const model = body.model || "omni-moderation-latest";
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const { provider } = parseModerationModel(model);
|
||||
|
||||
// Default to openai if no provider prefix
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts
|
||||
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -53,6 +54,10 @@ export async function POST(request, { params }) {
|
||||
body.model = `${providerAlias}/${body.model}`;
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// Validate provider match
|
||||
if (body.model) {
|
||||
const prefix = body.model.split("/")[0];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
|
||||
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -60,6 +61,10 @@ export async function POST(request, { params }) {
|
||||
body.model = `${rawProvider}/${body.model}`;
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
// Validate provider match
|
||||
const modelProvider = body.model.split("/")[0];
|
||||
if (modelProvider !== rawProvider) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/serv
|
||||
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -44,6 +45,10 @@ export async function POST(request) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
}
|
||||
|
||||
// Enforce API key policies (model restrictions + budget limits)
|
||||
const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
const { provider } = parseRerankModel(body.model);
|
||||
if (!provider) {
|
||||
return errorResponse(
|
||||
|
||||
@@ -12,7 +12,15 @@ export const CLAUDE_CONFIG = {
|
||||
clientId: process.env.CLAUDE_OAUTH_CLIENT_ID || "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
authorizeUrl: "https://claude.ai/oauth/authorize",
|
||||
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
|
||||
scopes: ["org:create_api_key", "user:profile", "user:inference"],
|
||||
redirectUri:
|
||||
process.env.CLAUDE_CODE_REDIRECT_URI || "https://platform.claude.com/oauth/code/callback",
|
||||
scopes: [
|
||||
"org:create_api_key",
|
||||
"user:profile",
|
||||
"user:inference",
|
||||
"user:sessions:claude_code",
|
||||
"user:mcp_servers",
|
||||
],
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { CLAUDE_CONFIG } from "../constants/oauth";
|
||||
export const claude = {
|
||||
config: CLAUDE_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
buildAuthUrl: (config, _redirectUri, state, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
@@ -16,7 +16,7 @@ export const claude = {
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
|
||||
exchangeToken: async (config, code, _redirectUri, codeVerifier, state) => {
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
@@ -36,7 +36,7 @@ export const claude = {
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri: config.redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -149,13 +149,14 @@ export default function ModelSelectModal({
|
||||
} else if (isCustomProvider) {
|
||||
const matchedNode = providerNodes.find((node) => node.id === providerId);
|
||||
const displayName = matchedNode?.name || providerInfo.name;
|
||||
const nodePrefix = matchedNode?.prefix || providerId; // Consider a more user-friendly fallback if providerId is a UUID
|
||||
|
||||
const nodeModels = Object.entries(modelAliases as Record<string, string>)
|
||||
.filter(([, fullModel]: [string, string]) => fullModel.startsWith(`${providerId}/`))
|
||||
.map(([aliasName, fullModel]: [string, string]) => ({
|
||||
id: fullModel.replace(`${providerId}/`, ""),
|
||||
name: aliasName,
|
||||
value: fullModel,
|
||||
value: `${nodePrefix}/${fullModel.replace(`${providerId}/`, "")}`,
|
||||
}));
|
||||
|
||||
// Merge custom models for custom providers
|
||||
@@ -164,7 +165,7 @@ export default function ModelSelectModal({
|
||||
.map((cm) => ({
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
value: `${providerId}/${cm.id}`,
|
||||
value: `${nodePrefix}/${cm.id}`,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
@@ -173,7 +174,7 @@ export default function ModelSelectModal({
|
||||
if (allModels.length > 0) {
|
||||
groups[providerId] = {
|
||||
name: displayName,
|
||||
alias: matchedNode?.prefix || providerId,
|
||||
alias: nodePrefix,
|
||||
color: providerInfo.color,
|
||||
models: allModels,
|
||||
isCustom: true,
|
||||
|
||||
@@ -34,9 +34,11 @@ export default function OAuthModal({
|
||||
const callbackProcessedRef = useRef(false);
|
||||
const flowStartedRef = useRef(false);
|
||||
|
||||
// Detect if running on localhost or private/LAN IP (client-side only)
|
||||
// Google OAuth rejects private IPs (192.168.x.x, 10.x.x.x, etc.) the same as localhost,
|
||||
// requiring device_id/device_name. Treat them identically for redirect URI construction.
|
||||
// Detect if running on true localhost vs LAN IP (client-side only)
|
||||
// - True localhost (127.0.0.1/localhost): popup auto-callback works
|
||||
// - LAN IPs (192.168.x, 10.x, 172.x): redirect URI uses localhost but callback
|
||||
// won't resolve back to the VPS, so use manual paste mode
|
||||
const [isTrueLocalhost, setIsTrueLocalhost] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const hostname = window.location.hostname;
|
||||
@@ -46,7 +48,9 @@ export default function OAuthModal({
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
|
||||
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";
|
||||
setIsLocalhost(isLocal);
|
||||
setIsTrueLocalhost(isTrulyLocal);
|
||||
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
|
||||
}
|
||||
}, []);
|
||||
@@ -272,8 +276,8 @@ export default function OAuthModal({
|
||||
|
||||
setAuthData({ ...data, redirectUri });
|
||||
|
||||
// For non-localhost: use manual input mode (user pastes callback URL)
|
||||
if (!isLocalhost) {
|
||||
// For non-true-localhost (LAN IPs, remote): use manual input mode (user pastes callback URL)
|
||||
if (!isTrueLocalhost) {
|
||||
setStep("input");
|
||||
window.open(data.authUrl, "oauth_auth");
|
||||
} else {
|
||||
@@ -290,7 +294,7 @@ export default function OAuthModal({
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
}
|
||||
}, [provider, isLocalhost, startPolling, onSuccess]);
|
||||
}, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess]);
|
||||
|
||||
// Reset guard when modal closes
|
||||
useEffect(() => {
|
||||
@@ -493,8 +497,8 @@ export default function OAuthModal({
|
||||
{step === "input" && !isDeviceCode && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Remote server info for Google OAuth providers */}
|
||||
{!isLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
{/* Remote/LAN server info for Google OAuth providers */}
|
||||
{!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">
|
||||
warning
|
||||
@@ -515,7 +519,7 @@ export default function OAuthModal({
|
||||
</div>
|
||||
)}
|
||||
{/* Generic remote info for other providers */}
|
||||
{!isLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
{!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.includes(provider) && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200">
|
||||
<span className="material-symbols-outlined text-sm align-middle mr-1">info</span>
|
||||
<strong>Remote access:</strong> Since you're accessing OmniRoute remotely,
|
||||
|
||||
@@ -140,25 +140,28 @@ export const CLI_TOOLS = {
|
||||
description: "Google Antigravity IDE with MITM",
|
||||
configType: "mitm",
|
||||
modelAliases: [
|
||||
"claude-opus-4-5-thinking",
|
||||
"claude-sonnet-4-5-thinking",
|
||||
"claude-sonnet-4-5",
|
||||
"gemini-3-pro-high",
|
||||
"claude-opus-4-6-thinking",
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
"gpt-oss-120b-medium",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-low",
|
||||
],
|
||||
defaultModels: [
|
||||
{
|
||||
id: "claude-opus-4-5-thinking",
|
||||
name: "Claude Opus 4.5 Thinking",
|
||||
alias: "claude-opus-4-5-thinking",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5-thinking",
|
||||
name: "Claude Sonnet 4.5 Thinking",
|
||||
alias: "claude-sonnet-4-5-thinking",
|
||||
},
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4-5" },
|
||||
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High", alias: "gemini-3-pro-high" },
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", alias: "gemini-3.1-pro-low" },
|
||||
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" },
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
alias: "claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6-thinking",
|
||||
name: "Claude Opus 4.6 Thinking",
|
||||
alias: "claude-opus-4-6-thinking",
|
||||
},
|
||||
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" },
|
||||
],
|
||||
},
|
||||
// HIDDEN: gemini-cli
|
||||
|
||||
@@ -223,14 +223,14 @@ export const DEFAULT_PRICING = {
|
||||
|
||||
// Antigravity (ag) - User-provided pricing
|
||||
ag: {
|
||||
"gemini-3-pro-low": {
|
||||
"gemini-3.1-pro-low": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
cached: 0.25,
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3-pro-high": {
|
||||
"gemini-3.1-pro-high": {
|
||||
input: 4.0,
|
||||
output: 18.0,
|
||||
cached: 0.5,
|
||||
@@ -244,34 +244,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cached: 0.03,
|
||||
reasoning: 3.75,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
"claude-sonnet-4-6": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 0.3,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"claude-sonnet-4-5-thinking": {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 0.3,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
},
|
||||
"claude-opus-4-5-thinking": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cached: 0.5,
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
@@ -279,6 +258,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"gpt-oss-120b-medium": {
|
||||
input: 0.5,
|
||||
output: 2.0,
|
||||
cached: 0.25,
|
||||
reasoning: 3.0,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
},
|
||||
|
||||
// GitHub Copilot (gh)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
|
||||
// ──────────────── Public Routes (No Auth Required) ────────────────
|
||||
@@ -78,6 +79,46 @@ export async function verifyAuth(request: any): Promise<string | null> {
|
||||
return "Authentication required";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is authenticated — boolean convenience wrapper for route handlers.
|
||||
*
|
||||
* Uses `cookies()` from next/headers (App Router compatible) and Bearer API key.
|
||||
* Returns true if authenticated, false otherwise.
|
||||
*
|
||||
* Unlike `verifyAuth`, this does NOT check `isAuthRequired()` — callers that
|
||||
* need to conditionally skip auth should check that separately.
|
||||
*/
|
||||
export async function isAuthenticated(request: Request): Promise<boolean> {
|
||||
// 1. Check API key (for external clients)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const apiKey = authHeader.slice(7);
|
||||
try {
|
||||
const { validateApiKey } = await import("@/lib/db/apiKeys");
|
||||
if (await validateApiKey(apiKey)) return true;
|
||||
} catch {
|
||||
// DB not ready or import error
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check JWT cookie (for dashboard session)
|
||||
if (process.env.JWT_SECRET) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("auth_token")?.value;
|
||||
if (token) {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
await jwtVerify(token, secret);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid/expired token or cookies not available
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is in the public (no-auth) allowlist.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API Key Policy Enforcement — Shared middleware for all /v1/* endpoints.
|
||||
*
|
||||
* Enforces API key policies: model restrictions and budget limits.
|
||||
* Should be called after API key authentication in every endpoint that
|
||||
* accepts a model parameter.
|
||||
*
|
||||
* @module shared/utils/apiKeyPolicy
|
||||
*/
|
||||
|
||||
import { extractApiKey } from "@/sse/services/auth";
|
||||
import { getApiKeyMetadata, isModelAllowedForKey } from "@/lib/localDb";
|
||||
import { checkBudget } from "@/domain/costRules";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import * as log from "@/sse/utils/logger";
|
||||
|
||||
/** Metadata stored for an API key in the local database. */
|
||||
export interface ApiKeyMetadata {
|
||||
id: string;
|
||||
name?: string;
|
||||
allowedModels?: string[];
|
||||
budget?: number;
|
||||
usedBudget?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ApiKeyPolicyResult {
|
||||
/** API key string (null if no key provided) */
|
||||
apiKey: string | null;
|
||||
/** Metadata from DB (null if no key or key not found) */
|
||||
apiKeyInfo: ApiKeyMetadata | null;
|
||||
/** If set, the request should be rejected with this Response */
|
||||
rejection: Response | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce API key policies for a request.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Model restriction — if the key has `allowedModels`, verify the requested model is permitted
|
||||
* 2. Budget limit — if the key has a budget configured, verify it hasn't been exceeded
|
||||
*
|
||||
* @param request - The incoming HTTP request
|
||||
* @param modelStr - The model ID from the request body
|
||||
* @returns ApiKeyPolicyResult with apiKey, metadata, and optional rejection response
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const policy = await enforceApiKeyPolicy(request, body.model);
|
||||
* if (policy.rejection) return policy.rejection;
|
||||
* // proceed with request, optionally use policy.apiKeyInfo
|
||||
* ```
|
||||
*/
|
||||
export async function enforceApiKeyPolicy(
|
||||
request: Request,
|
||||
modelStr: string | null
|
||||
): Promise<ApiKeyPolicyResult> {
|
||||
const apiKey = extractApiKey(request);
|
||||
|
||||
// No API key = local mode, skip policy checks
|
||||
if (!apiKey) {
|
||||
return { apiKey: null, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// Fetch key metadata (includes allowedModels)
|
||||
let apiKeyInfo: ApiKeyMetadata | null = null;
|
||||
try {
|
||||
apiKeyInfo = await getApiKeyMetadata(apiKey);
|
||||
} catch (error) {
|
||||
// If metadata fetch fails, don't block — degrade gracefully, but log for debugging
|
||||
log.warn("API_POLICY", "Failed to fetch API key metadata. Request will be allowed.", { error });
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// Key not found in DB — skip policy (auth layer handles validation)
|
||||
if (!apiKeyInfo) {
|
||||
return { apiKey, apiKeyInfo: null, rejection: null };
|
||||
}
|
||||
|
||||
// ── Check 1: Model restriction ──
|
||||
if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) {
|
||||
const allowed = await isModelAllowedForKey(apiKey, modelStr);
|
||||
if (!allowed) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.FORBIDDEN,
|
||||
`Model "${modelStr}" is not allowed for this API key`
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: Budget limit ──
|
||||
if (apiKeyInfo.id) {
|
||||
try {
|
||||
const budgetOk = checkBudget(apiKeyInfo.id);
|
||||
if (!budgetOk.allowed) {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyInfo,
|
||||
rejection: errorResponse(
|
||||
HTTP_STATUS.RATE_LIMITED,
|
||||
budgetOk.reason || "Budget limit exceeded"
|
||||
),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Budget check is best-effort — don't block on errors, but log them
|
||||
log.warn("API_POLICY", "Budget check failed. Request will be allowed.", { error });
|
||||
}
|
||||
}
|
||||
|
||||
return { apiKey, apiKeyInfo, rejection: null };
|
||||
}
|
||||
+15
-25
@@ -23,7 +23,7 @@ import {
|
||||
} from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import * as log from "../utils/logger";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh";
|
||||
import { getSettings, getCombos, getApiKeyMetadata } from "@/lib/localDb";
|
||||
import { getSettings, getCombos } from "@/lib/localDb";
|
||||
import { resolveProxyForConnection } from "@/lib/localDb";
|
||||
import { logProxyEvent } from "../../lib/proxyLogger";
|
||||
import { logTranslationEvent } from "../../lib/translatorEvents";
|
||||
@@ -34,8 +34,9 @@ import { getCircuitBreaker, CircuitBreakerOpenError } from "../../shared/utils/c
|
||||
import { isModelAvailable, setModelUnavailable } from "../../domain/modelAvailability";
|
||||
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
|
||||
import { generateRequestId } from "../../shared/utils/requestId";
|
||||
import { checkBudget, recordCost } from "../../domain/costRules";
|
||||
import { recordCost } from "../../domain/costRules";
|
||||
import { logAuditEvent } from "../../lib/compliance/index";
|
||||
import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy";
|
||||
|
||||
/**
|
||||
* Handle chat completion request
|
||||
@@ -97,15 +98,8 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
// Log API key (masked)
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const apiKey = extractApiKey(request);
|
||||
let apiKeyInfo = null;
|
||||
if (authHeader && apiKey) {
|
||||
const masked = log.maskKey(apiKey);
|
||||
log.debug("AUTH", `API Key: ${masked}`);
|
||||
try {
|
||||
apiKeyInfo = await getApiKeyMetadata(apiKey);
|
||||
} catch {
|
||||
apiKeyInfo = null;
|
||||
}
|
||||
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
||||
} else {
|
||||
log.debug("AUTH", "No API key provided (local mode)");
|
||||
}
|
||||
@@ -129,19 +123,14 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
}
|
||||
|
||||
// Pipeline: Budget check (if API key has budget limits)
|
||||
// Pipeline: API key policy enforcement (model restrictions + budget limits)
|
||||
telemetry.startPhase("policy");
|
||||
if (apiKeyInfo?.id) {
|
||||
try {
|
||||
const budgetOk = checkBudget(apiKeyInfo.id);
|
||||
if (!budgetOk.allowed) {
|
||||
log.warn("BUDGET", `API key ${apiKeyInfo.id} exceeded budget: ${budgetOk.reason}`);
|
||||
return errorResponse(429, budgetOk.reason || "Budget limit exceeded");
|
||||
}
|
||||
} catch {
|
||||
// Budget check is best-effort — don't block on errors
|
||||
}
|
||||
const policy = await enforceApiKeyPolicy(request, modelStr);
|
||||
if (policy.rejection) {
|
||||
log.warn("POLICY", `API key policy rejected: ${modelStr} (key=${policy.apiKeyInfo?.id || "unknown"})`);
|
||||
return policy.rejection;
|
||||
}
|
||||
const apiKeyInfo = policy.apiKeyInfo;
|
||||
telemetry.endPhase();
|
||||
|
||||
// Check if model is a combo (has multiple models with fallback)
|
||||
@@ -156,13 +145,14 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
// Pre-check function: skip models where all accounts are in cooldown
|
||||
// Uses modelAvailability module for TTL-based cooldowns
|
||||
const checkModelAvailable = async (modelString: string) => {
|
||||
const parsed = parseModel(modelString);
|
||||
const provider = parsed.provider;
|
||||
// Use getModelInfo to properly resolve custom prefixes
|
||||
const modelInfo = await getModelInfo(modelString);
|
||||
const provider = modelInfo.provider;
|
||||
if (!provider) return true; // can't determine provider, let it try
|
||||
|
||||
// Check domain-level availability (cooldown)
|
||||
if (!isModelAvailable(provider, parsed.model || modelString)) {
|
||||
log.debug("AVAILABILITY", `${provider}/${parsed.model} in cooldown, skipping`);
|
||||
if (!isModelAvailable(provider, modelInfo.model || modelString)) {
|
||||
log.debug("AVAILABILITY", `${provider}/${modelInfo.model} in cooldown, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+20
-14
@@ -22,22 +22,28 @@ export async function resolveModelAlias(alias) {
|
||||
export async function getModelInfo(modelStr) {
|
||||
const parsed = parseModel(modelStr);
|
||||
|
||||
if (!parsed.isAlias) {
|
||||
if (parsed.provider === parsed.providerAlias) {
|
||||
// Check OpenAI Compatible nodes
|
||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||
const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedOpenAI) {
|
||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||
}
|
||||
// Check custom provider nodes first (for both alias and non-alias formats)
|
||||
// Check custom provider nodes first (for both alias and non-alias formats)
|
||||
if (parsed.providerAlias || parsed.provider) {
|
||||
// Ensure prefixToCheck is always a concise identifier, not a full model string
|
||||
const prefixToCheck = parsed.providerAlias || parsed.provider;
|
||||
|
||||
// Check Anthropic Compatible nodes
|
||||
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
|
||||
const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias);
|
||||
if (matchedAnthropic) {
|
||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||
}
|
||||
// Check OpenAI Compatible nodes
|
||||
const openaiNodes = await getProviderNodes({ type: "openai-compatible" });
|
||||
const matchedOpenAI = openaiNodes.find((node) => node.prefix === prefixToCheck);
|
||||
if (matchedOpenAI) {
|
||||
return { provider: matchedOpenAI.id, model: parsed.model };
|
||||
}
|
||||
|
||||
// Check Anthropic Compatible nodes
|
||||
const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" });
|
||||
const matchedAnthropic = anthropicNodes.find((node) => node.prefix === prefixToCheck);
|
||||
if (matchedAnthropic) {
|
||||
return { provider: matchedAnthropic.id, model: parsed.model };
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed.isAlias) {
|
||||
return getModelInfoCore(modelStr, null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user