Compare commits

...

21 Commits

Author SHA1 Message Date
diegosouzapw 6dce45505c chore(release): v1.6.9
Build Electron Desktop App / Validate version (push) Failing after 40s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- PR #160: CopilotToolCard URL fix + chat model filter (alpgul)
- PR #161: Proxy port preservation, credential encoding, cache invalidation (ken2190)
- CHANGELOG: v1.6.9 entry
- Version bump: 1.6.8 → 1.6.9
2026-02-28 16:29:25 -03:00
Diego Rodrigues de Sa e Souza 014732788c Merge pull request #161 from ken2190/fix/proxy-logic-and-docker-build
fix: preserve explicit proxy port and fix Docker build
2026-02-28 16:28:28 -03:00
Diego Rodrigues de Sa e Souza 0e75d838ab Merge pull request #160 from alpgul/fix/base-url-and-chat-filter
fix: improve API base URL handling and filter for chat models in CopilotToolCard
2026-02-28 16:28:26 -03:00
Alptekin Gülcan 8383da8a50 fix: improve API base URL handling and filter for chat models in CopilotToolCard 2026-02-28 19:10:17 +00:00
duongvdo 199d173816 fix: preserve explicit proxy port (80/443) instead of defaulting to 8080
The URL parser silently strips default ports (80 for HTTP, 443 for HTTPS)
when constructing URL objects. This caused proxy connections to use port
8080 instead of the user-specified port 80, resulting in connection
timeouts. Fix by extracting the port from the raw URL string before
parsing and building the normalized URL manually to avoid the serializer
stripping it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:41:07 +07:00
diegosouzapw f2829441f0 chore(release): v1.6.8
Build Electron Desktop App / Validate version (push) Failing after 36s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
- Merged PR #159: Electron release workflow refactor (benzntech)
- Added app/ to .gitignore (Next.js App Router conflict fix)
- CHANGELOG: v1.6.8 entry
- Version bump: 1.6.7 → 1.6.8
2026-02-28 15:33:16 -03:00
diegosouzapw 21137bd84a fix: add app/ to .gitignore — prevents Next.js App Router conflict
The production standalone build directory (app/) created by scripts/prepublish.mjs
was conflicting with Next.js App Router detection. Next.js prioritizes root app/
over src/app/, causing all routes to return 404 in dev mode.

The package.json 'files' field still includes app/, so npm publish is unaffected.
2026-02-28 15:32:24 -03:00
Diego Rodrigues de Sa e Souza a05e51a577 Merge pull request #159 from benzntech/fix/electron-release-filter
fix: filter Electron release assets to installers only
2026-02-28 15:30:59 -03:00
benzntech 09a094629c fix: include arm64 dmg in release assets
- Add explicit pattern for *-arm64.dmg files
- Fixes Kilo bot review: *.dmg doesn't match -arm64.dmg
2026-02-28 23:34:37 +05:30
benzntech 90de0fbf68 docs: add installation instructions with macOS Gatekeeper workaround 2026-02-28 23:21:39 +05:30
benzntech c9cdd5109b feat: add Windows portable standalone exe
- NSIS installer: OmniRoute.Setup.X.Y.Z.exe (install to Program Files)
- Portable: OmniRoute.exe (run anywhere, no installation)
2026-02-28 23:19:20 +05:30
duongvdo 0e207dc5d2 fix: proxy logic bugs and Docker build failure
- URL-encode proxy credentials to handle special characters in passwords
- Decode URL-encoded credentials during legacy proxy migration
- Fix HTTPS proxy default port (443 instead of 8080) in frontend and migration
- Add dispatcher cache invalidation when proxy config changes
- Cast proxy port to number for SQLite INTEGER column in proxy logger
- Fix redundant .replace("//", "") in migration protocol parsing
- Copy postinstall script in Dockerfile before npm install

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:48:27 +07:00
benzntech 876a5a98f4 feat: add source code archives to releases
- Include .tar.gz and .zip of source code
- Uses git archive for clean export (excludes node_modules, build artifacts)
- Named: OmniRoute-vX.Y.Z.source.tar.gz / .zip
2026-02-28 23:16:43 +05:30
benzntech 8e82350d66 refactor: improve Electron release workflow
- Trigger on git tags (v*) instead of release.published
- Add manual workflow_dispatch for re-runs
- Add version validation step
- Use artifact upload/download pattern
- Single release job ensures all platforms complete first
- Prevents partial releases if one platform fails
2026-02-28 23:14:53 +05:30
benzntech de75ed1551 fix: upload only installer files to releases
- Filter to *.dmg, *.exe, *.AppImage, *.blockmap only
- Prevents uploading unpacked app contents (DLLs, JS files, images)
2026-02-28 21:52:52 +05:30
benzntech 87266104a3 Merge origin/main - sync with v1.6.7 release 2026-02-28 21:52:38 +05:30
benzntech fed8140404 chore: ignore electron build artifacts 2026-02-28 21:44:42 +05:30
diegosouzapw e4d83e91bb chore(release): prepare release v1.6.7
- CHANGELOG: add v1.6.7 entry (Copilot Config Generator #142)
- FEATURES.md: add Copilot to CLI Tools section
- Version bump: 1.6.6 → 1.6.7 (package.json + electron/package.json)
2026-02-28 11:53:39 -03:00
diegosouzapw a3153d893a feat: GitHub Copilot config generator for CLI Tools (#142)
Adds a Copilot configuration generator to the CLI Tools dashboard page.
Users can select models and generate the chatLanguageModels.json config
block for VS Code GitHub Copilot with the Azure vendor pattern.

Features:
- Bulk model selection from /v1/models (includes combos, custom, aliased)
- Search/filter for large model lists
- Configurable maxInputTokens, maxOutputTokens, toolCalling, vision
- One-click copy to clipboard
- Persistent model selection via localStorage
- Version compatibility warning (VS Code >= 1.109, Copilot >= v0.37)

Feedback from @alpgul applied:
- Use /v1/models instead of /api/models/alias (includes combo definitions)
- Use window.location.origin for URL (no port duplication in Docker)

Also: added electron/dist-electron/ to .gitignore (build artifact)
2026-02-28 11:31:55 -03:00
diegosouzapw be219449f9 chore(release): bump version to v1.6.6 2026-02-28 11:17:26 -03:00
diegosouzapw 06d193f0d9 fix: prevent auth bypass after onboarding (#151)
The 'no password' auth bypass check was meant for fresh installs only,
but it also fired after onboarding was complete if the password row
was missing from the database (e.g. after DB migration in v1.6.3).

Fix: Added !settings.setupComplete guard so the bypass only applies
before onboarding is done. Once setupComplete=true, auth is always
required regardless of whether the password key exists in the DB.

Files changed:
- src/proxy.ts (dashboard middleware)
- src/shared/utils/apiAuth.ts (isAuthRequired)
2026-02-28 11:16:23 -03:00
19 changed files with 749 additions and 48 deletions
+114 -9
View File
@@ -1,29 +1,71 @@
name: Build Electron Desktop App
on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.6.8)'
required: true
type: string
permissions:
contents: write
jobs:
validate:
name: Validate version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.validate.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate version format
id: validate
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
VERSION="${GITHUB_REF#refs/tags/}"
else
VERSION="${{ inputs.version }}"
fi
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Invalid version format. Expected: v1.6.8"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "✓ Valid version: $VERSION"
build:
name: Build Electron (${{ matrix.os }})
name: Build Electron (${{ matrix.platform }})
needs: validate
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: windows
- platform: windows
runner: windows-latest
target: win
- os: macos
ext: .exe
- platform: macos-intel
runner: macos-latest
target: mac
- os: linux
ext: .dmg
- platform: macos-arm64
runner: macos-latest
target: mac
ext: -arm64.dmg
- platform: linux
runner: ubuntu-latest
target: linux
ext: .AppImage
steps:
- name: Checkout
@@ -53,13 +95,76 @@ jobs:
working-directory: electron
run: npm install --no-audit --no-fund
- name: Build Electron for ${{ matrix.target }}
- name: Build Electron for ${{ matrix.platform }}
working-directory: electron
run: npm run build:${{ matrix.target }}
- name: Upload release assets
- name: Collect installers
run: |
mkdir -p release-assets
cd electron/dist-electron
# Copy only installer files for this platform
for file in *${{ matrix.ext }}; do
[ -f "$file" ] && cp "$file" ../../release-assets/
done
# Windows: also copy portable standalone exe
if [ "${{ matrix.platform }}" = "windows" ]; then
[ -f "OmniRoute.exe" ] && cp OmniRoute.exe ../../release-assets/
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: electron-${{ matrix.platform }}
path: release-assets/
release:
name: Create Release
needs: [validate, build]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- name: Create source archives
run: |
# Create source code archives (excluding dev dependencies and build artifacts)
export TARBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.tar.gz"
export ZIPBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.zip"
# Use git archive for clean source export
git archive --format=tar.gz --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$TARBALL"
git archive --format=zip --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$ZIPBALL"
echo "✓ Created source archives:"
ls -lh "release-assets/$TARBALL" "release-assets/$ZIPBALL"
- name: List release files
run: ls -la release-assets/
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: electron/dist-electron/**/*
tag_name: ${{ needs.validate.outputs.version }}
draft: false
prerelease: false
generate_release_notes: true
files: |
release-assets/*.dmg
release-assets/*-arm64.dmg
release-assets/*.exe
release-assets/*.AppImage
release-assets/*.blockmap
release-assets/*.source.tar.gz
release-assets/*.source.zip
release-assets/OmniRoute.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+8 -1
View File
@@ -103,6 +103,13 @@ app.log
# Backup directories
app.__qa_backup/
# Electron (subproject dependency lock)
# Production standalone build (created by scripts/prepublish.mjs)
# Conflicts with Next.js App Router detection in dev (root app/ shadows src/app/)
# npm publish still includes it via package.json "files" field
app/
# Electron (subproject dependency lock and build artifacts)
electron/package-lock.json
electron/dist-electron/
electron/node_modules/
icon.iconset/
+49
View File
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.6.9] — 2026-02-28
### 🐛 Fixed
- **Proxy Port Preservation** — `new URL()` silently strips default ports (80/443); proxy connections now extract the port from the raw URL string before parsing, preventing connection timeouts ([PR #161](https://github.com/diegosouzapw/OmniRoute/pull/161))
- **Proxy Credential Encoding** — URL-encode special characters in proxy username/password; decode during legacy migration ([PR #161](https://github.com/diegosouzapw/OmniRoute/pull/161))
- **HTTPS Proxy Default Port** — Changed from 8080 to 443 in frontend and migration logic ([PR #161](https://github.com/diegosouzapw/OmniRoute/pull/161))
- **Proxy Dispatcher Cache** — Invalidate cached dispatchers when proxy config is updated or deleted ([PR #161](https://github.com/diegosouzapw/OmniRoute/pull/161))
- **Proxy Logger SQLite Type** — Cast `proxyPort` to `Number` for INTEGER column ([PR #161](https://github.com/diegosouzapw/OmniRoute/pull/161))
- **CopilotToolCard URL** — Use `baseUrl` prop directly instead of redundant `window.location.origin`; filter to chat models only (`!m.type && !m.parent`) ([PR #160](https://github.com/diegosouzapw/OmniRoute/pull/160))
---
## [1.6.8] — 2026-02-28
### 🔧 Improved
- **Electron Release Workflow** — Refactored CI to trigger on git tags (`v*`) + manual dispatch, with version validation, artifact upload/download pattern across 3 platforms, and a single release job. Only installer files (`.dmg`, `.exe`, `.AppImage`) are uploaded — no more 5K+ unpacked files ([PR #159](https://github.com/diegosouzapw/OmniRoute/pull/159))
- **Windows Portable Exe** — Added standalone portable `.exe` build alongside the NSIS installer ([PR #159](https://github.com/diegosouzapw/OmniRoute/pull/159))
- **Source Code Archives** — Releases now include `OmniRoute-vX.Y.Z.source.tar.gz` and `.zip` via `git archive` ([PR #159](https://github.com/diegosouzapw/OmniRoute/pull/159))
- **Installation Docs** — Added platform-specific installation instructions with macOS Gatekeeper workaround ([PR #159](https://github.com/diegosouzapw/OmniRoute/pull/159))
### 🐛 Fixed
- **Next.js App Router Conflict** — Added `app/` (production standalone build) to `.gitignore`. This directory was conflicting with Next.js App Router detection in dev mode, causing all routes to return 404
- **Git Tracking** — Added `electron/node_modules/` to `.gitignore`
---
## [1.6.7] — 2026-02-28
### ✨ New Feature
- **GitHub Copilot Configuration Generator** — New tool on the CLI Tools dashboard page. Select models and generate the `chatLanguageModels.json` config block for VS Code GitHub Copilot using the Azure vendor pattern. Features: bulk model selection from `/v1/models` (includes combos/custom), search/filter, configurable tokens/tool-calling/vision, one-click copy, persistent selection via localStorage. Version compatibility warning for VS Code ≥ 1.109 / Copilot Chat ≥ v0.37 ([#142](https://github.com/diegosouzapw/OmniRoute/issues/142))
### 🧹 Housekeeping
- Added `electron/dist-electron/` to `.gitignore` (build artifact)
---
## [1.6.6] — 2026-02-28
### 🔒 Security Fix
- **Auth bypass after onboarding** — Fixed regression where users could access the dashboard without authentication after upgrading from older versions. The "no password" safeguard (for fresh installs) was incorrectly firing after onboarding was complete, allowing unauthenticated access when `setupComplete=true` but the password DB row was missing ([#151](https://github.com/diegosouzapw/OmniRoute/issues/151))
---
## [1.6.5] — 2026-02-28
### 🖥️ Electron Desktop
+1
View File
@@ -2,6 +2,7 @@ FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
COPY scripts/postinstall.mjs ./scripts/postinstall.mjs
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY . ./
+1 -1
View File
@@ -56,7 +56,7 @@ General settings, system storage, backup management (export/import database), ap
## 🔧 CLI Tools
One-click configuration for AI coding tools: Claude Code, Codex CLI, Gemini CLI, OpenClaw, Kilo Code, and Antigravity.
One-click configuration for AI coding tools: Claude Code, Codex CLI, Gemini CLI, OpenClaw, Kilo Code, Antigravity, and **GitHub Copilot** (config generator for `chatLanguageModels.json`).
![CLI Tools Dashboard](screenshots/07-cli-tools.png)
+40 -2
View File
@@ -100,10 +100,48 @@ npm run build:linux
Built applications are placed in `dist-electron/`:
- Windows: `.exe` installer (NSIS)
- macOS: `.dmg` installer (universal)
- Windows: `.exe` installer (NSIS) + portable `.exe`
- macOS: `.dmg` installer (Intel + Apple Silicon)
- Linux: `.AppImage`
## Installation
### macOS
1. Download the latest `.dmg` from the [Releases](https://github.com/diegosouzapw/OmniRoute/releases) page.
2. Open the `.dmg` file.
3. Drag `OmniRoute.app` to the Applications folder.
4. Launch from Applications.
> ⚠️ **Note:** The app is not signed with an Apple Developer certificate yet. If macOS blocks the app, run:
> ```bash
> xattr -cr /Applications/OmniRoute.app
> ```
> Or right-click the app → Open → Open (to bypass Gatekeeper on first launch).
### Windows
**Installer (Recommended):**
1. Download `OmniRoute.Setup.*.exe` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Run the installer.
3. Launch from Start Menu or Desktop shortcut.
**Portable (No Installation):**
1. Download `OmniRoute.exe` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Run directly from any folder.
### Linux
1. Download the `.AppImage` from [Releases](https://github.com/diegosouzapw/OmniRoute/releases).
2. Make it executable:
```bash
chmod +x OmniRoute-*.AppImage
```
3. Run:
```bash
./OmniRoute-*.AppImage
```
## Features
- **Server Readiness** — Waits for health check before showing window
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "1.6.4",
"version": "1.6.9",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": "OmniRoute Team",
@@ -63,6 +63,12 @@
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
}
],
"icon": "assets/icon.ico"
+66 -12
View File
@@ -4,13 +4,39 @@ import { socksDispatcher } from "fetch-socks";
const DISPATCHER_CACHE_KEY = Symbol.for("omniroute.proxyDispatcher.cache");
const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "socks5:"]);
function getDispatcherCache() {
function getDispatcherCache(): Map<string, any> {
if (!globalThis[DISPATCHER_CACHE_KEY]) {
globalThis[DISPATCHER_CACHE_KEY] = new Map();
}
return globalThis[DISPATCHER_CACHE_KEY];
}
/**
* Clear all cached proxy dispatchers.
* Call this when proxy configuration changes to avoid stale connections.
*/
export function clearDispatcherCache() {
const cache = getDispatcherCache();
cache.clear();
}
/**
* Extract the port from a proxy URL string before URL parsing.
* `new URL("http://host:80")` strips port 80 since it's the HTTP default,
* but proxy servers commonly listen on port 80/443, so we need to preserve it.
*/
function extractExplicitPort(urlStr) {
try {
// Match port in the host portion: "scheme://[user:pass@]host:PORT[/...]"
const match = urlStr.match(/:\/\/(?:[^@]*@)?[^:/\s]+:(\d+)/);
if (match) {
const port = Number(match[1]);
if (Number.isInteger(port) && port >= 1 && port <= 65535) return String(port);
}
} catch {}
return null;
}
function defaultPortForProtocol(protocol) {
if (protocol === "https:" || protocol === "wss:") return "443";
if (protocol === "socks5:") return "1080";
@@ -26,13 +52,28 @@ function normalizePort(port, protocol) {
return String(parsed);
}
/**
* Build a proxy URL string manually from parsed URL components.
* We cannot use URL.toString() because the URL serializer silently strips
* default ports (80 for http, 443 for https). Proxy servers commonly
* listen on these ports, so we must always include the port explicitly.
*/
function buildProxyUrlString(parsed, port) {
const auth =
parsed.username
? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@`
: "";
return `${parsed.protocol}//${auth}${parsed.hostname}:${port}`;
}
export function isSocks5ProxyEnabled() {
return process.env.ENABLE_SOCKS5_PROXY === "true";
}
export function proxyUrlForLogs(proxyUrl) {
const explicitPort = extractExplicitPort(proxyUrl);
const parsed = new URL(proxyUrl);
const port = parsed.port || defaultPortForProtocol(parsed.protocol);
const port = explicitPort || parsed.port || defaultPortForProtocol(parsed.protocol);
return `${parsed.protocol}//${parsed.hostname}:${port}`;
}
@@ -41,6 +82,11 @@ export function normalizeProxyUrl(
source = "proxy",
{ allowSocks5 = isSocks5ProxyEnabled() } = {}
) {
// Extract the explicit port from the raw URL string BEFORE parsing,
// because `new URL()` silently strips default ports (80 for http,
// 443 for https), which are valid and common for proxy servers.
const explicitPort = extractExplicitPort(proxyUrl);
let parsed;
try {
parsed = new URL(proxyUrl);
@@ -62,8 +108,12 @@ export function normalizeProxyUrl(
throw new Error(`[ProxyDispatcher] Invalid ${source} host`);
}
parsed.port = normalizePort(parsed.port, parsed.protocol);
return parsed.toString();
// Use the explicit port from the raw string if present, otherwise apply default.
const port = explicitPort || normalizePort(parsed.port, parsed.protocol);
// Build the URL string manually instead of using parsed.toString(),
// which would strip default ports (80/443) and break the proxy connection.
return buildProxyUrlString(parsed, port);
}
export function proxyConfigToUrl(proxyConfig, { allowSocks5 = isSocks5ProxyEnabled() } = {}) {
@@ -93,14 +143,15 @@ export function proxyConfigToUrl(proxyConfig, { allowSocks5 = isSocks5ProxyEnabl
}
const port = normalizePort(proxyConfig.port, protocol);
const proxyUrl = new URL(`${type}://${proxyConfig.host}:${port}`);
if (proxyConfig.username) {
proxyUrl.username = proxyConfig.username;
proxyUrl.password = proxyConfig.password || "";
}
// Build the URL string manually to preserve the port through normalization.
const auth = proxyConfig.username
? `${encodeURIComponent(proxyConfig.username)}:${proxyConfig.password ? encodeURIComponent(proxyConfig.password) : ""}@`
: "";
return normalizeProxyUrl(proxyUrl.toString(), "context proxy", { allowSocks5 });
const proxyUrlStr = `${type}://${auth}${proxyConfig.host}:${port}`;
return normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 });
}
export function createProxyDispatcher(proxyUrl) {
@@ -111,11 +162,14 @@ export function createProxyDispatcher(proxyUrl) {
if (dispatcher) return dispatcher;
const parsed = new URL(normalizedUrl);
const explicitPort = extractExplicitPort(normalizedUrl);
const port = explicitPort || normalizePort(parsed.port, parsed.protocol);
if (parsed.protocol === "socks5:") {
const socksOptions: Record<string, any> = {
type: 5,
host: parsed.hostname,
port: Number(normalizePort(parsed.port, parsed.protocol)),
port: Number(port),
};
if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username);
if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password);
@@ -126,4 +180,4 @@ export function createProxyDispatcher(proxyUrl) {
dispatcherCache.set(normalizedUrl, dispatcher);
return dispatcher;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "1.6.5",
"version": "1.6.9",
"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,6 +17,7 @@ import {
KiloToolCard,
DefaultToolCard,
AntigravityToolCard,
CopilotToolCard,
} from "./components";
import { useTranslations } from "next-intl";
@@ -32,7 +33,6 @@ export default function CLIToolsPageClient({ machineId }) {
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
const [apiBaseUrl, setApiBaseUrl] = useState("");
useEffect(() => {
fetchConnections();
@@ -47,12 +47,6 @@ export default function CLIToolsPageClient({ machineId }) {
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
if (typeof window !== "undefined") {
const protocol = window.location.protocol;
const hostname = window.location.hostname;
const apiPort = data?.apiPort || 20128;
setApiBaseUrl(`${protocol}//${hostname}:${apiPort}`);
}
}
} catch (error) {
console.log("Error loading cloud settings:", error);
@@ -154,9 +148,8 @@ export default function CLIToolsPageClient({ machineId }) {
if (cloudEnabled && CLOUD_URL) {
return CLOUD_URL;
}
if (apiBaseUrl) {
return apiBaseUrl;
}
// Use window.location.origin directly — works correctly in Docker/reverse-proxy
// Per @alpgul feedback: don't use baseUrl prop (has port duplication issues)
if (typeof window !== "undefined") {
return window.location.origin;
}
@@ -263,6 +256,16 @@ export default function CLIToolsPageClient({ machineId }) {
cloudEnabled={cloudEnabled}
/>
);
case "copilot":
return (
<CopilotToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
default:
return (
<DefaultToolCard
@@ -0,0 +1,420 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
/**
* GitHub Copilot Configuration Generator
*
* Generates the chatLanguageModels.json block for VS Code GitHub Copilot
* using the Azure vendor pattern as required by Copilot's architecture.
*
* Feature request: https://github.com/diegosouzapw/OmniRoute/issues/142
*/
export default function CopilotToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
apiKeys,
activeProviders = [],
hasActiveProviders = false,
cloudEnabled = false,
batchStatus,
}) {
const t = useTranslations("cliTools");
const [copiedField, setCopiedField] = useState<string | null>(null);
const [selectedModels, setSelectedModels] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set<string>();
try {
const saved = localStorage.getItem("omniroute-copilot-selected-models");
return saved ? new Set<string>(JSON.parse(saved)) : new Set<string>();
} catch {
return new Set<string>();
}
});
const [selectedApiKey, setSelectedApiKey] = useState(() => {
if (typeof window !== "undefined") {
const savedKey = localStorage.getItem("omniroute-cli-key-copilot");
if (savedKey && apiKeys?.some((k: any) => k.key === savedKey)) return savedKey;
}
return apiKeys?.length > 0 ? apiKeys[0].key : "";
});
const [maxInputTokens, setMaxInputTokens] = useState(128000);
const [maxOutputTokens, setMaxOutputTokens] = useState(16000);
const [toolCalling, setToolCalling] = useState(true);
const [vision, setVision] = useState(false);
const [allModels, setAllModels] = useState<Array<{ value: string; label: string }>>([]);
const [modelsLoaded, setModelsLoaded] = useState(false);
const [searchFilter, setSearchFilter] = useState("");
// Fetch ALL models dynamically from /v1/models (includes combos, custom, aliased)
// Per @alpgul feedback: /api/models/alias doesn't include combo definitions
useEffect(() => {
if (!isExpanded || modelsLoaded) return;
let cancelled = false;
fetch("/v1/models")
.then((res) => res.json())
.then((data) => {
if (cancelled) return;
const modelList = (data.data || [])
.filter((m: any) => m && !m.type && !m.parent && m.id) // Only chat models with valid IDs
.map((m: any) => ({
value: m.id,
label: m.id,
}));
setAllModels(modelList);
setModelsLoaded(true);
})
.catch(() => {
if (!cancelled) setModelsLoaded(true);
});
return () => {
cancelled = true;
};
}, [isExpanded, modelsLoaded]);
// Filter models by search
const availableModels = searchFilter
? allModels.filter((m) => m.label.toLowerCase().includes(searchFilter.toLowerCase()))
: allModels;
// Persist selection
useEffect(() => {
if (selectedModels.size > 0) {
localStorage.setItem(
"omniroute-copilot-selected-models",
JSON.stringify([...selectedModels])
);
}
}, [selectedModels]);
const toggleModel = (modelValue: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(modelValue)) {
next.delete(modelValue);
} else {
next.add(modelValue);
}
return next;
});
};
const selectAll = () => {
setSelectedModels(new Set(allModels.map((m) => m.value)));
};
const deselectAll = () => {
setSelectedModels(new Set());
};
const getBaseUrlForConfig = () => {
const url = baseUrl;
return `${url}/v1/chat/completions`;
};
// Generate the Copilot chatLanguageModels.json config
const generateConfig = () => {
const models = [...selectedModels].map((modelId) => ({
id: modelId,
name: modelId,
url: `${getBaseUrlForConfig()}#models.ai.azure.com`,
toolCalling,
vision,
maxInputTokens,
maxOutputTokens,
}));
const config = {
name: "OmniRoute",
vendor: "azure",
apiKey: `\${input:chat.lm.secret.omniroute}`,
models,
};
return JSON.stringify(config, null, 2);
};
const handleCopy = async (text: string, field: string) => {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const handleApiKeyChange = (value: string) => {
setSelectedApiKey(value);
if (value) localStorage.setItem("omniroute-cli-key-copilot", value);
};
return (
<Card padding="sm" className="overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg flex items-center justify-center shrink-0">
<Image
src={tool.image || "/providers/copilot.png"}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500" />
{t("guide")}
</span>
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span
className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}
>
expand_more
</span>
</div>
{/* Expanded content */}
{isExpanded && (
<div className="mt-6 pt-6 border-t border-border">
<div className="flex flex-col gap-5">
{/* Info box */}
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium">GitHub Copilot Config Generator</p>
<p className="mt-1 text-xs opacity-80">
Generates the{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
chatLanguageModels.json
</code>{" "}
block for VS Code GitHub Copilot using the Azure vendor pattern. Select the models
you want, then copy the JSON into your config file.
</p>
</div>
</div>
{/* Version compatibility warning */}
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-lg">warning</span>
<p className="text-xs text-yellow-600 dark:text-yellow-400">
This configuration uses the Azure vendor workaround for custom model lists. Tested
with <strong>VS Code 1.109</strong> and{" "}
<strong>GitHub Copilot Chat v0.37</strong>. Future extension updates may change
this behavior.
</p>
</div>
{/* Step 2: API Key (if cloud enabled) */}
{cloudEnabled && apiKeys?.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<div
className="size-6 rounded-full flex items-center justify-center text-xs font-semibold text-white"
style={{ backgroundColor: tool.color }}
>
1
</div>
<span className="font-medium text-sm">API Key</span>
</div>
<select
value={selectedApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key: any) => (
<option key={key.id} value={key.key}>
{key.key}
</option>
))}
</select>
</div>
)}
{/* Step 3: Model Selection */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="size-6 rounded-full flex items-center justify-center text-xs font-semibold text-white"
style={{ backgroundColor: tool.color }}
>
{cloudEnabled && apiKeys?.length > 0 ? "2" : "1"}
</div>
<span className="font-medium text-sm">
Select Models ({selectedModels.size}/{availableModels.length})
</span>
</div>
<div className="flex gap-2">
<button
onClick={selectAll}
className="px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
>
Select All
</button>
<button
onClick={deselectAll}
className="px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
>
Clear
</button>
</div>
</div>
{/* Search filter */}
<div className="mb-2">
<input
type="text"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder="Filter models..."
className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
{!modelsLoaded && allModels.length === 0 ? (
<div className="flex items-center gap-2 p-3 text-text-muted text-sm">
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
<span>Loading models...</span>
</div>
) : availableModels.length === 0 && allModels.length === 0 ? (
<div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500 text-lg">warning</span>
<p className="text-sm text-yellow-600 dark:text-yellow-400">
{t("noActiveProviders")}
</p>
</div>
) : (
<div className="max-h-64 overflow-y-auto rounded-lg border border-border bg-bg-secondary">
{availableModels.map((model) => (
<label
key={model.value}
className="flex items-center gap-3 px-3 py-2 hover:bg-bg-tertiary cursor-pointer border-b border-border last:border-0 transition-colors"
>
<input
type="checkbox"
checked={selectedModels.has(model.value)}
onChange={() => toggleModel(model.value)}
className="rounded border-border text-primary accent-[#1F6FEB]"
/>
<span className="text-sm font-mono truncate">{model.label}</span>
</label>
))}
</div>
)}
</div>
{/* Step 4: Advanced options (collapsible) */}
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-sm text-text-muted hover:text-text-main transition-colors">
<span className="material-symbols-outlined text-base group-open:rotate-90 transition-transform">
chevron_right
</span>
Advanced Options
</summary>
<div className="mt-3 grid grid-cols-2 gap-3 pl-6">
<div>
<label className="text-xs text-text-muted block mb-1">Max Input Tokens</label>
<input
type="number"
value={maxInputTokens}
onChange={(e) => setMaxInputTokens(Number(e.target.value) || 128000)}
className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">Max Output Tokens</label>
<input
type="number"
value={maxOutputTokens}
onChange={(e) => setMaxOutputTokens(Number(e.target.value) || 16000)}
className="w-full px-3 py-1.5 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={toolCalling}
onChange={(e) => setToolCalling(e.target.checked)}
className="rounded border-border accent-[#1F6FEB]"
/>
<span className="text-sm">Tool Calling</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={vision}
onChange={(e) => setVision(e.target.checked)}
className="rounded border-border accent-[#1F6FEB]"
/>
<span className="text-sm">Vision</span>
</label>
</div>
</details>
{/* Step 5: Generated config */}
{selectedModels.size > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="size-6 rounded-full flex items-center justify-center text-xs font-semibold text-white"
style={{ backgroundColor: tool.color }}
>
{cloudEnabled && apiKeys?.length > 0 ? "3" : "2"}
</div>
<span className="font-medium text-sm">
Copy Config ({selectedModels.size} model{selectedModels.size !== 1 ? "s" : ""}
)
</span>
</div>
<Button
variant="primary"
size="sm"
onClick={() => handleCopy(generateConfig(), "config")}
>
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedField === "config" ? "check" : "content_copy"}
</span>
{copiedField === "config" ? t("copied") : t("copyConfig")}
</Button>
</div>
<pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto max-h-80">
<code className="text-xs font-mono whitespace-pre text-text-main">
{generateConfig()}
</code>
</pre>
{/* Usage instructions */}
<div className="mt-3 p-3 bg-bg-secondary rounded-lg border border-border">
<p className="text-xs text-text-muted">
<span className="font-medium text-text-main">Paste into: </span>
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/10">
~/.config/Code/User/chatLanguageModels.json
</code>
</p>
<p className="text-xs text-text-muted mt-1">
Then reload VS Code and set the API key in the input prompt.
</p>
</div>
</div>
)}
</div>
</div>
)}
</Card>
);
}
@@ -6,3 +6,4 @@ export { default as ClineToolCard } from "./ClineToolCard";
export { default as KiloToolCard } from "./KiloToolCard";
export { default as DefaultToolCard } from "./DefaultToolCard";
export { default as AntigravityToolCard } from "./AntigravityToolCard";
export { default as CopilotToolCard } from "./CopilotToolCard";
+3
View File
@@ -5,6 +5,7 @@ import {
deleteProxyForLevel,
resolveProxyForConnection,
} from "../../../../lib/localDb";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
@@ -129,6 +130,7 @@ export async function PUT(request) {
const body = await request.json();
const normalizedBody = normalizeProxyPayload(body);
const updated = await setProxyConfig(normalizedBody);
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
const status = Number(error?.status) || 500;
@@ -155,6 +157,7 @@ export async function DELETE(request) {
}
const updated = await deleteProxyForLevel(level, id);
clearDispatcherCache();
return Response.json(updated);
} catch (error) {
return Response.json(
+4 -4
View File
@@ -211,11 +211,11 @@ function migrateProxyEntry(value: any) {
try {
const url = new URL(value);
return {
type: url.protocol.replace(":", "").replace("//", "") || "http",
type: url.protocol.replace(":", "") || "http",
host: url.hostname,
port: url.port || (url.protocol === "socks5:" ? "1080" : "8080"),
username: url.username || "",
password: url.password || "",
port: url.port || (url.protocol === "socks5:" ? "1080" : url.protocol === "https:" ? "443" : "8080"),
username: url.username ? decodeURIComponent(url.username) : "",
password: url.password ? decodeURIComponent(url.password) : "",
};
} catch {
const parts = value.split(":");
+1 -1
View File
@@ -134,7 +134,7 @@ export function logProxyEvent(entry: Partial<ProxyLogEntry>) {
status: log.status,
proxyType: log.proxy?.type || null,
proxyHost: log.proxy?.host || null,
proxyPort: log.proxy?.port || null,
proxyPort: log.proxy?.port ? Number(log.proxy.port) : null,
level: log.level,
levelId: log.levelId,
provider: log.provider,
+3 -3
View File
@@ -131,9 +131,9 @@ export async function proxy(request) {
if (settings.requireLogin === false) {
return response;
}
// Skip auth if no password has been set yet (fresh install with no env override)
// This prevents an unresolvable loop where requireLogin=true but no password exists
if (!settings.password && !process.env.INITIAL_PASSWORD) {
// Skip auth ONLY for fresh installs (before onboarding) where no password exists yet.
// Once setupComplete is true, always require auth — prevents bypass if password row is lost (#151)
if (!settings.setupComplete && !settings.password && !process.env.INITIAL_PASSWORD) {
return response;
}
} catch (err) {
+5 -1
View File
@@ -48,7 +48,11 @@ export default function ProxyConfigModal({ isOpen, onClose, level, levelId, leve
const [hasOwnProxy, setHasOwnProxy] = useState(false);
const [formError, setFormError] = useState(null);
const getDefaultPort = (type) => (type === "socks5" ? "1080" : "8080");
const getDefaultPort = (type) => {
if (type === "socks5") return "1080";
if (type === "https") return "443";
return "8080";
};
// Load existing proxy config when modal opens
useEffect(() => {
+8
View File
@@ -164,6 +164,14 @@ export const CLI_TOOLS = {
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium", alias: "gpt-oss-120b-medium" },
],
},
copilot: {
id: "copilot",
name: "GitHub Copilot",
image: "/providers/copilot.png",
color: "#1F6FEB",
description: "GitHub Copilot Chat — VS Code Extension",
configType: "custom",
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",
+4 -2
View File
@@ -134,8 +134,10 @@ export async function isAuthRequired(): Promise<boolean> {
try {
const settings = await getSettings();
if (settings.requireLogin === false) return false;
// If no password set and no env override, don't require auth (fresh install)
if (!settings.password && !process.env.INITIAL_PASSWORD) return false;
// Only skip auth for fresh installs (not yet onboarded) with no password.
// Once setupComplete is true, always require auth — prevents bypass if password row is lost (#151)
if (!settings.setupComplete && !settings.password && !process.env.INITIAL_PASSWORD)
return false;
return true;
} catch {
// On error, require auth (secure by default)