Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f279368531 | |||
| 4cc44b37bb | |||
| e121fec599 | |||
| 6c669abb23 | |||
| 0b677677d1 | |||
| 49f9fee446 | |||
| 9588c1ea3e | |||
| c665b01e89 | |||
| 5c2db0134f | |||
| de162eb719 | |||
| 33297e0226 | |||
| a07e643020 | |||
| 304664b318 | |||
| 8372a3c7ca | |||
| 69bbc0a2a1 | |||
| 5bfe881fc8 | |||
| 44f691bea4 | |||
| e59db45f5b | |||
| f4087694b1 | |||
| 2c63e0fdd6 | |||
| 29bb71373e | |||
| ed48858635 | |||
| 6f5c8389eb | |||
| 52bd72f449 |
+112
-25
@@ -14,6 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,6 +33,19 @@ jobs:
|
||||
- run: npm run typecheck:core
|
||||
- run: npm run typecheck:noimplicit:core
|
||||
|
||||
i18n-matrix:
|
||||
name: Build language matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
langs: ${{ steps.langs.outputs.langs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: langs
|
||||
run: |
|
||||
LANG_DIR="src/i18n/messages"
|
||||
LANGS=$(ls "$LANG_DIR"/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$' | jq -R . | jq -s . | jq -c .)
|
||||
echo "langs=${LANGS}" >> $GITHUB_OUTPUT
|
||||
|
||||
i18n:
|
||||
name: i18n Validation
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,31 +60,17 @@ jobs:
|
||||
- uses: actions/setup-python@v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Validate ${{ matrix.lang }}
|
||||
run: |
|
||||
echo "Validating language: ${{ matrix.lang }}"
|
||||
python3 scripts/validate_translation.py quick -l '${{ matrix.lang }}'
|
||||
- name: Report to summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "### ${{ matrix.lang }} Translation Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
python3 scripts/validate_translation.py quick -l '${{ matrix.lang }}' >> $GITHUB_STEP_SUMMARY 2>&1
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
python3 scripts/validate_translation.py quick -l '${{ matrix.lang }}' > result.txt
|
||||
|
||||
i18n-matrix:
|
||||
name: Build language matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
langs: ${{ steps.langs.outputs.langs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Generate language list
|
||||
id: langs
|
||||
run: |
|
||||
LANG_DIR="src/i18n/messages"
|
||||
LANGS=$(ls "$LANG_DIR"/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$' | jq -R . | jq -s . | jq -c .)
|
||||
echo "langs=${LANGS}" >> $GITHUB_OUTPUT
|
||||
- name: Upload result
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-${{ matrix.lang }}
|
||||
path: result.txt
|
||||
|
||||
security:
|
||||
name: Security Audit
|
||||
@@ -137,9 +137,6 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:coverage
|
||||
- name: Check coverage threshold
|
||||
run: |
|
||||
echo "Coverage report generated. Check output for threshold compliance."
|
||||
|
||||
test-e2e:
|
||||
name: E2E Tests
|
||||
@@ -192,3 +189,93 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:security
|
||||
|
||||
# 🔥 DASHBOARD
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
needs:
|
||||
- lint
|
||||
- security
|
||||
- build
|
||||
- test-unit
|
||||
- test-coverage
|
||||
- test-e2e
|
||||
- test-integration
|
||||
- test-security
|
||||
- i18n
|
||||
|
||||
steps:
|
||||
- name: Download i18n results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: results
|
||||
|
||||
- name: Generate dashboard
|
||||
run: |
|
||||
status() {
|
||||
case "$1" in
|
||||
success) echo "🟢 PASS" ;;
|
||||
failure) echo "🔴 FAIL" ;;
|
||||
cancelled) echo "⚫ CANCELLED" ;;
|
||||
*) echo "🟡 UNKNOWN" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "# 🚀 CI Dashboard" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 🔹 CORE
|
||||
echo "## 🧱 Core Checks" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Security Audit | $(status '${{ needs.security.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 🔹 BUILD
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🏗️ Build" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 🔹 TESTS
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🧪 Tests" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Suite | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 🔹 I18N
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🌍 Translations" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
total=0
|
||||
langs=0
|
||||
|
||||
for dir in results/*; do
|
||||
file="$dir/result.txt"
|
||||
val=$(sed -r 's/\x1B\[[0-9;]*[mK]//g' "$file" | grep "Untranslated:" | awk '{print $2}')
|
||||
val=${val:-0}
|
||||
total=$((total + val))
|
||||
langs=$((langs + 1))
|
||||
done
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|--------|------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Languages checked | $langs |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Total untranslated | $total |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ "$total" -gt 0 ]; then
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "⚠️ **Translations need attention**" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ **All translations complete**" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
+24
-2
@@ -2,11 +2,26 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.4.4] - 2026-04-02
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Responses API Token Reporting:** Emit `response.completed` with correct `input_tokens`/`output_tokens` fields for Codex CLI clients, fixing token usage display (#909 — thanks @christopher-s).
|
||||
- **SQLite WAL Checkpoint on Shutdown:** Flush WAL changes into the primary database file during graceful shutdown/restart, preventing data loss on Docker container stops (#905 — thanks @rdself).
|
||||
- **Graceful Shutdown Signal:** Changed `/api/restart` and `/api/shutdown` routes from `process.exit(0)` to `process.kill(SIGTERM)`, ensuring the shutdown handler runs before exit.
|
||||
- **Docker Stop Grace Period:** Added `stop_grace_period: 40s` to Docker Compose files and `--stop-timeout 40` to Docker run examples.
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
- **AGENTS.md rewrite:** Condensed from 297→153 lines. Added build/lint/test commands (including single-test execution), code style guidelines (Prettier, TypeScript, ESLint, naming, imports, error handling, security), and trimmed verbose architecture tables for AI agent consumption.
|
||||
- Closed 5 resolved/not-a-bug issues (#872, #814, #816, #890, #877).
|
||||
- Triaged 6 issues with needs-info requests (#892, #887, #886, #865, #895, #870).
|
||||
- Responded to CLI detection tracking issue (#863) with contributor guidance.
|
||||
|
||||
## [3.4.2] - 2026-04-01
|
||||
---
|
||||
|
||||
## [3.4.3] - 2026-04-02
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -17,9 +32,15 @@
|
||||
- **UI & Customization:** Added custom favicon support, appearance tabs, wired whitelabeling to the sidebar, and added Windsurf guide steps across all 33 languages.
|
||||
- **Log Retention:** Unified request log retention and artifacts natively.
|
||||
- **Model Enhancements:** Added explicit `contextLength` for all opencode-zen models.
|
||||
- **i18n & translations:** Integrated 33 language translations natively, including placeholder CI validations and Chinese documentation updates (#873, #869).
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Qwen OAuth Mapping:** Reverted `id_token` reliance to `access_token` and enabled dynamic `resource_url` API endpoint injection for proper regional routing (#900).
|
||||
- **Model Sync Engine:** Stored the strict internal Provider ID in `getCustomModels()` sync routines instead of the UI Channel Alias format, preventing SQLite catalog insertion failures (#903).
|
||||
- **Claude Code & Codex:** Standardized non-streaming blank responses to Anthropic-formatted `(empty response)` to prevent CLI proxy crashes (#866).
|
||||
- **CC Compatible Routing:** Resolved duplicate `/v1` endpoint collision during path concatenation for generic Claude Code gateways (#904).
|
||||
- **Antigravity Dashboards:** Blocked unlimited quota models from falsely registering as exhausted `100% Usage` limit states in the Provider Usage UI (#857).
|
||||
- **Claude Image Passthrough:** Fixed Claude models missing image block passthroughs (#898).
|
||||
- **Gemini CLI Routing:** Resolved 403 authorization lockouts and content accumulation issues by refreshing the project ID via `loadCodeAssist` (#868).
|
||||
- **Antigravity Stability:** Corrected model access lists, enforced 404 lockouts, fixed 429 cascades locking out standard connections, and capped `gemini-3.1-pro` output tokens (#885).
|
||||
@@ -30,6 +51,7 @@
|
||||
- **CI Stabilization:** Fixed failing analytics/settings Playwright selectors and request assertions so GitHub Actions E2E runs pass reliably across localized UIs and switch-based controls.
|
||||
- **Deterministic Tests:** Removed date-sensitive quota fixtures from Copilot usage tests and aligned idempotency/model catalog tests with the merged runtime behavior.
|
||||
- **MCP Type Hardening:** Removed zero-budget explicit `any` regressions from the MCP server tool registration path.
|
||||
- **Model Sync Engine:** Bypassed destructive `replace` overrides when the provider's auto-sync yields an empty model list, maintaining stability for dynamic catalogs (#899).
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
|
||||
@@ -979,6 +979,7 @@ OmniRoute is available as a public Docker image on [Docker Hub](https://hub.dock
|
||||
docker run -d \
|
||||
--name omniroute \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 40 \
|
||||
-p 20128:20128 \
|
||||
-v omniroute-data:/app/data \
|
||||
diegosouzapw/omniroute:latest
|
||||
@@ -993,6 +994,7 @@ cp .env.example .env
|
||||
docker run -d \
|
||||
--name omniroute \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 40 \
|
||||
--env-file .env \
|
||||
-p 20128:20128 \
|
||||
-v omniroute-data:/app/data \
|
||||
@@ -1016,6 +1018,8 @@ Notes:
|
||||
- Quick Tunnel URLs are temporary and change after every restart.
|
||||
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
|
||||
- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container.
|
||||
- SQLite runs in WAL mode. `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`.
|
||||
- The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40` (or similar) so manual stops do not cut off shutdown cleanup.
|
||||
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
|
||||
|
||||
**Using Docker Compose with Caddy (HTTPS Auto-TLS):**
|
||||
|
||||
@@ -19,6 +19,7 @@ services:
|
||||
target: runner-cli
|
||||
image: omniroute:prod
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 40s
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
x-common: &common
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 40s
|
||||
env_file: .env
|
||||
environment:
|
||||
- DATA_DIR=/app/data # Must match the volume mount below
|
||||
|
||||
@@ -983,6 +983,7 @@ OmniRoute is available as a public Docker image on [Docker Hub](https://hub.dock
|
||||
docker run -d \
|
||||
--name omniroute \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 40 \
|
||||
-p 20128:20128 \
|
||||
-v omniroute-data:/app/data \
|
||||
diegosouzapw/omniroute:latest
|
||||
@@ -997,6 +998,7 @@ cp .env.example .env
|
||||
docker run -d \
|
||||
--name omniroute \
|
||||
--restart unless-stopped \
|
||||
--stop-timeout 40 \
|
||||
--env-file .env \
|
||||
-p 20128:20128 \
|
||||
-v omniroute-data:/app/data \
|
||||
@@ -1020,6 +1022,8 @@ Notes:
|
||||
- Quick Tunnel URLs are temporary and change after every restart.
|
||||
- Managed install currently supports Linux, macOS, and Windows on `x64` / `arm64`.
|
||||
- Docker images bundle system CA roots and pass them to managed `cloudflared`, which avoids TLS trust failures when the tunnel bootstraps inside the container.
|
||||
- SQLite uses WAL mode. Let `docker stop` finish cleanly so OmniRoute can checkpoint the latest changes back into `storage.sqlite`.
|
||||
- The bundled Compose files already use a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40` (or similar) so manual stops do not interrupt shutdown cleanup.
|
||||
- Set `CLOUDFLARED_BIN=/absolute/path/to/cloudflared` if you want OmniRoute to use an existing binary instead of downloading one.
|
||||
|
||||
**Using Docker Compose with Caddy (HTTPS Auto-TLS):**
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.4.2
|
||||
version: 3.4.4
|
||||
description: |
|
||||
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
|
||||
endpoint that routes requests to multiple AI providers with load balancing,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.4",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
|
||||
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
|
||||
|
||||
**Current version:** 3.4.2
|
||||
**Current version:** 3.4.3
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
└── .env.example # Environment variable template
|
||||
```
|
||||
|
||||
## Key Features (v3.4.2)
|
||||
## Key Features (v3.4.3)
|
||||
|
||||
### Core Proxy
|
||||
- **60+ AI providers** with automatic format translation
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
|
||||
import {
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
joinBaseUrlAndPath,
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
import { isClaudeCodeCompatible } from "../services/provider.ts";
|
||||
|
||||
@@ -32,7 +32,10 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (isClaudeCodeCompatible(this.provider)) {
|
||||
return joinBaseUrlAndPath(baseUrl, customPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
return joinClaudeCodeCompatibleUrl(
|
||||
baseUrl,
|
||||
customPath || CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
);
|
||||
}
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
return `${normalized}${customPath || "/messages"}`;
|
||||
@@ -46,6 +49,10 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
return `${this.config.baseUrl}?beta=true`;
|
||||
case "gemini":
|
||||
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||
case "qwen": {
|
||||
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
|
||||
return `https://${resourceUrl || "portal.qwen.ai"}/v1/chat/completions`;
|
||||
}
|
||||
default:
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
@@ -429,15 +429,15 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
|
||||
|
||||
if (messageObj.content !== undefined && messageObj.content !== null) {
|
||||
hasTextOrReasoning = true;
|
||||
const resolvedText = toString(messageObj.content);
|
||||
content.push({
|
||||
type: "text",
|
||||
text: toString(messageObj.content),
|
||||
text: resolvedText === "" ? "(empty response)" : resolvedText,
|
||||
});
|
||||
} else if (!hasTextOrReasoning) {
|
||||
// Claude format expects a text block even before tool calls (or if empty)
|
||||
content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
text: "(empty response)",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.4",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092;
|
||||
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01";
|
||||
@@ -45,14 +45,39 @@ export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined)
|
||||
return normalized.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
export function joinBaseUrlAndPath(baseUrl: string, path: string): string {
|
||||
const normalizedBase = stripAnthropicMessagesSuffix(baseUrl).replace(/\/$/, "");
|
||||
export function stripClaudeCodeCompatibleEndpointSuffix(
|
||||
baseUrl: string | null | undefined
|
||||
): string {
|
||||
const normalized = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
if (!normalized) return "";
|
||||
return normalized.replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
function joinNormalizedBaseUrlAndPath(baseUrl: string, path: string): string {
|
||||
const normalizedBase = String(baseUrl || "").replace(/\/$/, "");
|
||||
const normalizedPath = String(path || "").startsWith("/")
|
||||
? String(path)
|
||||
: `/${String(path || "")}`;
|
||||
const versionMatch = normalizedBase.match(/(\/v\d+)$/i);
|
||||
if (
|
||||
versionMatch &&
|
||||
normalizedPath.toLowerCase().startsWith(`${versionMatch[1].toLowerCase()}/`)
|
||||
) {
|
||||
return `${normalizedBase}${normalizedPath.slice(versionMatch[1].length)}`;
|
||||
}
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function joinBaseUrlAndPath(baseUrl: string, path: string): string {
|
||||
return joinNormalizedBaseUrlAndPath(stripAnthropicMessagesSuffix(baseUrl), path);
|
||||
}
|
||||
|
||||
export function joinClaudeCodeCompatibleUrl(baseUrl: string, path: string): string {
|
||||
return joinNormalizedBaseUrlAndPath(stripClaudeCodeCompatibleEndpointSuffix(baseUrl), path);
|
||||
}
|
||||
|
||||
export function buildClaudeCodeCompatibleHeaders(
|
||||
apiKey: string,
|
||||
stream = false,
|
||||
@@ -245,6 +270,31 @@ function buildClaudeCodeCompatibleMessages(messages: MessageLike[]) {
|
||||
merged.push({ role: message.role, content: [...message.content] });
|
||||
}
|
||||
|
||||
// CC-compatible sites we tested reject assistant-prefill shaped requests even
|
||||
// when Anthropic would normally allow them. Keep assistant/model history, but
|
||||
// drop trailing assistant turns so the upstream request ends on a user turn.
|
||||
while (merged.length > 0 && merged[merged.length - 1].role === "assistant") {
|
||||
merged.pop();
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
const fallbackText = converted
|
||||
.flatMap((message) => message.content)
|
||||
.map((block) => toNonEmptyString(block.text))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
if (fallbackText) {
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text", text: fallbackText, cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = merged.length - 1; i >= 0; i--) {
|
||||
if (merged[i].role !== "user") continue;
|
||||
const lastBlock = merged[i].content[merged[i].content.length - 1];
|
||||
@@ -340,14 +390,12 @@ function convertClaudeCodeCompatibleTool(tool: unknown) {
|
||||
const rawTool = readRecord(tool);
|
||||
if (!rawTool) return null;
|
||||
|
||||
const toolData =
|
||||
rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool;
|
||||
const toolData = rawTool.type === "function" ? readRecord(rawTool.function) || rawTool : rawTool;
|
||||
|
||||
const name = toNonEmptyString(toolData.name);
|
||||
if (!name) return null;
|
||||
|
||||
const rawSchema =
|
||||
readRecord(toolData.parameters) ||
|
||||
const rawSchema = readRecord(toolData.parameters) ||
|
||||
readRecord(toolData.input_schema) || { type: "object", properties: {}, required: [] };
|
||||
const inputSchema =
|
||||
rawSchema.type === "object" && !readRecord(rawSchema.properties)
|
||||
|
||||
@@ -3,8 +3,7 @@ import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import {
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
joinBaseUrlAndPath,
|
||||
stripAnthropicMessagesSuffix,
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
} from "./claudeCodeCompatible.ts";
|
||||
|
||||
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
|
||||
@@ -205,7 +204,7 @@ export function buildProviderUrl(
|
||||
if (isAnthropicCompatible(provider)) {
|
||||
const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
|
||||
if (isClaudeCodeCompatible(provider)) {
|
||||
return joinBaseUrlAndPath(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
return joinClaudeCodeCompatibleUrl(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
}
|
||||
return buildAnthropicCompatibleUrl(baseUrl);
|
||||
}
|
||||
|
||||
@@ -324,9 +324,12 @@ export async function refreshQwenToken(refreshToken, log) {
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.id_token || tokens.access_token,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: tokens.resource_url
|
||||
? { resourceUrl: tokens.resource_url }
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
|
||||
@@ -267,6 +267,15 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
||||
finalResults.push(...(Array.isArray(converted) ? converted : [converted]));
|
||||
}
|
||||
}
|
||||
// Flush: pass null to source-format translator even when Step 1 produced no output.
|
||||
// This is critical for formats like openai-responses that emit terminal events
|
||||
// (e.g., response.completed with total_tokens) in their flush handler.
|
||||
if (chunk === null && results.length === 0) {
|
||||
const converted = fromOpenAI(null, state);
|
||||
if (converted) {
|
||||
finalResults.push(...(Array.isArray(converted) ? converted : [converted]));
|
||||
}
|
||||
}
|
||||
results = finalResults;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,24 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
||||
return flushEvents(state);
|
||||
}
|
||||
|
||||
if (!chunk.choices?.length) {
|
||||
// Capture usage from usage-only chunks (stream_options.include_usage)
|
||||
if (chunk.usage) {
|
||||
state.usage = chunk.usage;
|
||||
// Capture usage from any chunk that carries it (usage-only chunks OR final chunks with finish_reason)
|
||||
// Normalize Chat Completions format (prompt_tokens/completion_tokens) to Responses API format
|
||||
// (input_tokens/output_tokens) so response.completed always has the fields Codex expects.
|
||||
if (chunk.usage) {
|
||||
const u = chunk.usage;
|
||||
const input_tokens = u.input_tokens ?? u.prompt_tokens ?? 0;
|
||||
const output_tokens = u.output_tokens ?? u.completion_tokens ?? 0;
|
||||
state.usage = {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens: u.total_tokens ?? input_tokens + output_tokens,
|
||||
};
|
||||
if (u.prompt_tokens_details?.cached_tokens) {
|
||||
state.usage.input_tokens_details = { cached_tokens: u.prompt_tokens_details.cached_tokens };
|
||||
}
|
||||
}
|
||||
|
||||
if (!chunk.choices?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
Generated
+3
-16
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -3013,19 +3013,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -21081,7 +21068,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.4.2"
|
||||
"version": "3.4.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.4",
|
||||
"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": {
|
||||
@@ -116,7 +116,6 @@
|
||||
"uuid": "^13.0.0",
|
||||
"wreq-js": "^2.0.1",
|
||||
"yazl": "^3.3.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
|
||||
@@ -470,7 +470,7 @@ interface EditCompatibleNodeModalProps {
|
||||
|
||||
const CC_COMPATIBLE_LABEL = "CC Compatible";
|
||||
const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details";
|
||||
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true";
|
||||
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
||||
const CC_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
|
||||
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
|
||||
@@ -5104,7 +5104,11 @@ function EditCompatibleNodeModal({
|
||||
apiType: node.apiType || "chat",
|
||||
baseUrl:
|
||||
node.baseUrl ||
|
||||
(isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"),
|
||||
(isCcCompatible
|
||||
? "https://api.anthropic.com"
|
||||
: isAnthropic
|
||||
? "https://api.anthropic.com/v1"
|
||||
: "https://api.openai.com/v1"),
|
||||
chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath: node.modelsPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_MODELS_PATH : ""),
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
const CC_COMPATIBLE_LABEL = "CC Compatible";
|
||||
const ADD_CC_COMPATIBLE_LABEL = "Add CC Compatible";
|
||||
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/messages?beta=true";
|
||||
const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
||||
const CC_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
|
||||
|
||||
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
|
||||
@@ -1295,7 +1295,7 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
});
|
||||
@@ -1335,7 +1335,7 @@ function AddCcCompatibleModal({ isOpen, onClose, onCreated }) {
|
||||
setFormData({
|
||||
name: "",
|
||||
prefix: "",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
modelsPath: CC_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
});
|
||||
|
||||
@@ -203,6 +203,9 @@ export function parseQuotaData(provider, data) {
|
||||
case "antigravity":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
|
||||
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
|
||||
return;
|
||||
}
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(modelKey, quota, {
|
||||
modelKey: modelKey,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
updateProviderConnection,
|
||||
updateProviderNode,
|
||||
} from "@/models";
|
||||
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { updateProviderNodeSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -16,6 +17,20 @@ function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function sanitizeAnthropicBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
// PUT /api/provider-nodes/[id] - Update provider node
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
let rawBody;
|
||||
@@ -58,8 +73,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
// Sanitize Base URL for Anthropic Compatible
|
||||
if (node.type === "anthropic-compatible") {
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/$/, "");
|
||||
sanitizedBaseUrl = sanitizedBaseUrl.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
sanitizedBaseUrl = isClaudeCodeCompatibleProvider(id)
|
||||
? sanitizeClaudeCodeCompatibleBaseUrl(sanitizedBaseUrl)
|
||||
: sanitizeAnthropicBaseUrl(sanitizedBaseUrl);
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
|
||||
@@ -25,6 +25,13 @@ function sanitizeAnthropicBaseUrl(baseUrl: string) {
|
||||
.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
// GET /api/provider-nodes - List all provider nodes
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -86,9 +93,11 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "CC Compatible provider is disabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
const sanitizedBaseUrl = sanitizeAnthropicBaseUrl(
|
||||
baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl
|
||||
);
|
||||
const rawBaseUrl = baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
|
||||
const sanitizedBaseUrl =
|
||||
compatMode === "cc"
|
||||
? sanitizeClaudeCodeCompatibleBaseUrl(rawBaseUrl)
|
||||
: sanitizeAnthropicBaseUrl(rawBaseUrl);
|
||||
|
||||
const node = await createProviderNode({
|
||||
id:
|
||||
|
||||
@@ -11,6 +11,13 @@ function sanitizeAnthropicBaseUrl(baseUrl: string) {
|
||||
.replace(/\/messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) {
|
||||
return (baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "")
|
||||
.replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, "");
|
||||
}
|
||||
|
||||
// POST /api/provider-nodes/validate - Validate API key against base URL
|
||||
export async function POST(request) {
|
||||
let rawBody;
|
||||
@@ -48,7 +55,7 @@ export async function POST(request) {
|
||||
const result = await validateClaudeCodeCompatibleProvider({
|
||||
apiKey,
|
||||
providerSpecificData: {
|
||||
baseUrl: sanitizeAnthropicBaseUrl(baseUrl),
|
||||
baseUrl: sanitizeClaudeCodeCompatibleBaseUrl(baseUrl),
|
||||
chatPath: chatPath || undefined,
|
||||
modelsPath: modelsPath || undefined,
|
||||
},
|
||||
|
||||
@@ -123,6 +123,8 @@ function getModelSyncChannelLabel(connection: unknown) {
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const start = Date.now();
|
||||
const { id } = await params;
|
||||
let logProvider = "unknown";
|
||||
let channelLabel: string | null = null;
|
||||
|
||||
try {
|
||||
if (!(await isAuthenticated(request)) && !isModelSyncInternalRequest(request)) {
|
||||
@@ -137,7 +139,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const providerLabel = getModelSyncChannelLabel(connection);
|
||||
logProvider = toNonEmptyString(connection.provider) || "unknown";
|
||||
channelLabel = getModelSyncChannelLabel(connection);
|
||||
|
||||
// Fetch models from the existing /api/providers/[id]/models endpoint
|
||||
const origin = new URL(request.url).origin;
|
||||
@@ -160,7 +163,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
path: `/api/providers/${id}/models`,
|
||||
status: modelsRes.status,
|
||||
model: "model-sync",
|
||||
provider: providerLabel,
|
||||
provider: logProvider,
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration,
|
||||
@@ -177,7 +180,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
const fetchedModels = modelsData.models || [];
|
||||
|
||||
// Filter out models already in the built-in registry
|
||||
const registryIds = new Set(getModelsByProviderId(connection.provider).map((m: any) => m.id));
|
||||
const registryIds = new Set(getModelsByProviderId(logProvider).map((m: any) => m.id));
|
||||
|
||||
// Replace the full model list
|
||||
const models = fetchedModels
|
||||
@@ -188,14 +191,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
}))
|
||||
.filter((m: any) => m.id && !registryIds.has(m.id));
|
||||
|
||||
const previousModels = await getCustomModels(connection.provider);
|
||||
const replaced = await replaceCustomModels(connection.provider, models);
|
||||
const previousModels = await getCustomModels(logProvider);
|
||||
const replaced = await replaceCustomModels(logProvider, models);
|
||||
const modelChanges = summarizeModelChanges(previousModels, replaced);
|
||||
|
||||
let syncedAliases = 0;
|
||||
if (usesManagedAvailableModels(connection.provider)) {
|
||||
if (usesManagedAvailableModels(logProvider)) {
|
||||
const aliasSync = await syncManagedAvailableModelAliases(
|
||||
connection.provider,
|
||||
logProvider,
|
||||
models.map((model: any) => model.id)
|
||||
);
|
||||
syncedAliases = aliasSync.assignedAliases.length;
|
||||
@@ -207,7 +210,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
path: `/api/providers/${id}/models`,
|
||||
status: 200,
|
||||
model: "model-sync",
|
||||
provider: providerLabel,
|
||||
provider: logProvider,
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration: Date.now() - start,
|
||||
@@ -215,8 +218,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
responseBody: {
|
||||
syncedModels: models.length,
|
||||
syncedAliases,
|
||||
provider: connection.provider,
|
||||
channel: providerLabel,
|
||||
provider: logProvider,
|
||||
channel: channelLabel,
|
||||
modelChanges,
|
||||
},
|
||||
});
|
||||
@@ -224,7 +227,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
provider: connection.provider,
|
||||
provider: logProvider,
|
||||
syncedModels: replaced.length,
|
||||
syncedAliases,
|
||||
modelChanges,
|
||||
@@ -238,12 +241,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
path: `/api/providers/${id}/sync-models`,
|
||||
status: 500,
|
||||
model: "model-sync",
|
||||
provider: "unknown",
|
||||
provider: logProvider,
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration: Date.now() - start,
|
||||
error: error.message || "Sync failed",
|
||||
requestType: "model-sync",
|
||||
...(channelLabel
|
||||
? {
|
||||
responseBody: {
|
||||
channel: channelLabel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}).catch(() => {});
|
||||
|
||||
return NextResponse.json({ error: error.message || "Failed to sync models" }, { status: 500 });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
// Graceful restart: exit with code 0 so the process manager (pm2/systemd) restarts
|
||||
// Graceful restart: SIGTERM flows through the shutdown handler before the process manager restarts
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
process.kill(process.pid, "SIGTERM");
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ status: "restarting" });
|
||||
|
||||
@@ -4,7 +4,7 @@ export async function POST() {
|
||||
const response = NextResponse.json({ success: true, message: "Shutting down..." });
|
||||
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
process.kill(process.pid, "SIGTERM");
|
||||
}, 500);
|
||||
|
||||
return response;
|
||||
|
||||
@@ -682,6 +682,30 @@
|
||||
"0": "Kiro CLI uses YAML config."
|
||||
}
|
||||
},
|
||||
"windsurf": {
|
||||
"steps": {
|
||||
"1": {
|
||||
"title": "Open AI Settings",
|
||||
"desc": "Click the AI Settings icon in Windsurf or go to Settings"
|
||||
},
|
||||
"2": {
|
||||
"title": "Add Custom Provider",
|
||||
"desc": "Select \"Add custom provider\" (OpenAI-compatible)"
|
||||
},
|
||||
"3": {
|
||||
"title": "Base URL",
|
||||
"desc": "http://127.0.0.1:20128/v1"
|
||||
},
|
||||
"4": {
|
||||
"title": "API Key",
|
||||
"desc": "Select your OmniRoute API key"
|
||||
},
|
||||
"5": {
|
||||
"title": "Select Model",
|
||||
"desc": "Choose a model from the dropdown"
|
||||
}
|
||||
}
|
||||
},
|
||||
"windsurf": {
|
||||
"steps": {
|
||||
"1": {
|
||||
|
||||
+36
-5
@@ -12,6 +12,7 @@ import { runMigrations } from "./migrationRunner";
|
||||
|
||||
type SqliteDatabase = import("better-sqlite3").Database;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type CheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
|
||||
|
||||
// ──────────────── Environment Detection ────────────────
|
||||
|
||||
@@ -323,6 +324,12 @@ function setDb(db: SqliteDatabase | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
function checkpointDb(db: SqliteDatabase, mode: CheckpointMode = "TRUNCATE"): boolean {
|
||||
if (isCloud || isBuildPhase || !SQLITE_FILE) return false;
|
||||
db.pragma(`wal_checkpoint(${mode})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureProviderConnectionsColumns(db: SqliteDatabase) {
|
||||
try {
|
||||
const columns = db.prepare("PRAGMA table_info(provider_connections)").all() as Array<{
|
||||
@@ -523,15 +530,39 @@ export function getDbInstance(): SqliteDatabase {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDbInstance(options?: { checkpointMode?: CheckpointMode | null }): boolean {
|
||||
const db = getDb();
|
||||
if (!db) return false;
|
||||
|
||||
const checkpointMode = options?.checkpointMode ?? "TRUNCATE";
|
||||
|
||||
try {
|
||||
if (checkpointMode) {
|
||||
try {
|
||||
if (checkpointDb(db, checkpointMode)) {
|
||||
console.log(`[DB] SQLite WAL checkpoint completed (${checkpointMode}).`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[DB] WAL checkpoint failed during close (${checkpointMode}):`, message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (db.open) db.close();
|
||||
} finally {
|
||||
setDb(null);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton (used by restore).
|
||||
*/
|
||||
export function resetDbInstance() {
|
||||
const db = getDb();
|
||||
if (db) {
|
||||
db.close();
|
||||
setDb(null);
|
||||
}
|
||||
closeDbInstance();
|
||||
}
|
||||
|
||||
// ──────────────── JSON → SQLite Migration ────────────────
|
||||
|
||||
@@ -96,11 +96,9 @@ async function waitForDrain(): Promise<void> {
|
||||
*/
|
||||
async function cleanup(): Promise<void> {
|
||||
try {
|
||||
const { getDbInstance } = await import("@/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
if (db && typeof db.close === "function") {
|
||||
db.close();
|
||||
console.log("[Shutdown] SQLite database closed.");
|
||||
const { closeDbInstance } = await import("@/lib/db/core");
|
||||
if (closeDbInstance()) {
|
||||
console.log("[Shutdown] SQLite database checkpointed and closed.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
|
||||
|
||||
@@ -60,7 +60,7 @@ export const qwen = {
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: tokens.id_token || tokens.access_token,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
idToken: tokens.id_token,
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
buildClaudeCodeCompatibleValidationPayload,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
joinBaseUrlAndPath,
|
||||
stripClaudeCodeCompatibleEndpointSuffix,
|
||||
stripAnthropicMessagesSuffix,
|
||||
} from "@omniroute/open-sse/services/claudeCodeCompatible.ts";
|
||||
import {
|
||||
@@ -24,6 +26,10 @@ function normalizeAnthropicBaseUrl(baseUrl: string) {
|
||||
return stripAnthropicMessagesSuffix(baseUrl || "");
|
||||
}
|
||||
|
||||
function normalizeClaudeCodeCompatibleBaseUrl(baseUrl: string) {
|
||||
return stripClaudeCodeCompatibleEndpointSuffix(baseUrl || "");
|
||||
}
|
||||
|
||||
function addModelsSuffix(baseUrl: string) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return "";
|
||||
@@ -576,7 +582,7 @@ export async function validateClaudeCodeCompatibleProvider({
|
||||
apiKey,
|
||||
providerSpecificData = {},
|
||||
}: any) {
|
||||
const baseUrl = normalizeAnthropicBaseUrl(providerSpecificData.baseUrl);
|
||||
const baseUrl = normalizeClaudeCodeCompatibleBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "No base URL configured for CC Compatible provider" };
|
||||
}
|
||||
@@ -586,7 +592,7 @@ export async function validateClaudeCodeCompatibleProvider({
|
||||
const defaultHeaders = buildClaudeCodeCompatibleHeaders(apiKey, false);
|
||||
|
||||
try {
|
||||
const modelsRes = await fetch(joinBaseUrlAndPath(baseUrl, modelsPath), {
|
||||
const modelsRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, modelsPath), {
|
||||
method: "GET",
|
||||
headers: defaultHeaders,
|
||||
});
|
||||
@@ -608,7 +614,7 @@ export async function validateClaudeCodeCompatibleProvider({
|
||||
const sessionId = JSON.parse(payload.metadata.user_id).session_id;
|
||||
|
||||
try {
|
||||
const messagesRes = await fetch(joinBaseUrlAndPath(baseUrl, chatPath), {
|
||||
const messagesRes = await fetch(joinClaudeCodeCompatibleUrl(baseUrl, chatPath), {
|
||||
method: "POST",
|
||||
headers: buildClaudeCodeCompatibleHeaders(apiKey, false, sessionId),
|
||||
body: JSON.stringify(payload),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const usageService = await import("./open-sse/services/usage.ts");
|
||||
|
||||
test("antigravity fraction logic", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (url.includes("loadCodeAssist")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
currentTier: { id: "pro", name: "Pro" },
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
models: {
|
||||
"gemini-1.5-pro": {
|
||||
quotaInfo: {
|
||||
remainingFraction: 0,
|
||||
resetTime: "2025-05-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await usageService.getUsageForProvider({
|
||||
provider: "antigravity",
|
||||
accessToken: "test",
|
||||
});
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { translateNonStreamingResponse } from "./open-sse/handlers/responseTranslator.ts";
|
||||
import { FORMATS } from "./open-sse/translator/formats.ts";
|
||||
console.log(
|
||||
translateNonStreamingResponse(
|
||||
{ object: "chat.completion", choices: [] },
|
||||
FORMATS.CLAUDE,
|
||||
FORMATS.OPENAI
|
||||
)
|
||||
);
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
} = await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
const providerNodesRoute = await import("../../src/app/api/provider-nodes/route.ts");
|
||||
@@ -50,7 +51,7 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupported roles", () => {
|
||||
test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping trailing assistant prefill", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: {
|
||||
reasoning_effort: "xhigh",
|
||||
@@ -78,6 +79,7 @@ test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupporte
|
||||
{ role: "user", content: [{ type: "text", text: "u1" }, { type: "image_url" }] },
|
||||
{ role: "model", content: "a1" },
|
||||
{ role: "user", content: [{ type: "text", text: "u2" }, { type: "tool_result" }] },
|
||||
{ role: "model", content: "prefill" },
|
||||
],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
@@ -119,6 +121,26 @@ test("buildClaudeCodeCompatibleRequest keeps order/text while mapping unsupporte
|
||||
assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1");
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest falls back to a user turn when the source only has assistant/model text", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: {
|
||||
messages: [{ role: "model", content: "draft" }],
|
||||
},
|
||||
normalizedBody: {
|
||||
messages: [{ role: "model", content: "draft" }],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
sessionId: "session-only-assistant",
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.messages, [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "draft", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest honors token priority fields", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: { max_completion_tokens: 321 },
|
||||
@@ -159,6 +181,30 @@ test("buildClaudeCodeCompatibleRequest omits auto tool_choice while preserving t
|
||||
assert.equal(payload.tool_choice, undefined);
|
||||
});
|
||||
|
||||
test("joinClaudeCodeCompatibleUrl preserves a single /v1 segment for CC paths", () => {
|
||||
assert.equal(
|
||||
joinClaudeCodeCompatibleUrl(
|
||||
"https://proxy.example.com",
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
),
|
||||
"https://proxy.example.com/v1/messages?beta=true"
|
||||
);
|
||||
assert.equal(
|
||||
joinClaudeCodeCompatibleUrl(
|
||||
"https://proxy.example.com/v1",
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
),
|
||||
"https://proxy.example.com/v1/messages?beta=true"
|
||||
);
|
||||
assert.equal(
|
||||
joinClaudeCodeCompatibleUrl(
|
||||
"https://proxy.example.com/v1/messages?beta=true",
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
),
|
||||
"https://proxy.example.com/v1/messages?beta=true"
|
||||
);
|
||||
});
|
||||
|
||||
test("DefaultExecutor uses CC-compatible path and headers", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
const credentials = {
|
||||
@@ -172,7 +218,7 @@ test("DefaultExecutor uses CC-compatible path and headers", () => {
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("claude-sonnet-4-6", true, 0, credentials),
|
||||
`https://proxy.example.com/v1${CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH}`
|
||||
"https://proxy.example.com/v1/messages?beta=true"
|
||||
);
|
||||
|
||||
const headers = executor.buildHeaders(credentials, true);
|
||||
@@ -213,10 +259,7 @@ test("validateProviderApiKey uses CC skeleton request after /models fallback", a
|
||||
assert.match(result.warning, /reached upstream/i);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => `${call.method} ${call.url}`),
|
||||
[
|
||||
"GET https://proxy.example.com/v1/models",
|
||||
"POST https://proxy.example.com/v1/messages?beta=true",
|
||||
]
|
||||
["GET https://proxy.example.com/models", "POST https://proxy.example.com/v1/messages?beta=true"]
|
||||
);
|
||||
assert.equal(calls[1].body.model, "claude-sonnet-4-6");
|
||||
assert.equal(calls[1].body.messages[0].role, "user");
|
||||
@@ -265,7 +308,7 @@ test("provider-nodes create route creates CC node with dedicated prefix when ena
|
||||
assert.equal(response.status, 201);
|
||||
const data = await response.json();
|
||||
assert.match(data.node.id, /^anthropic-compatible-cc-/);
|
||||
assert.equal(data.node.baseUrl, "https://proxy.example.com/v1");
|
||||
assert.equal(data.node.baseUrl, "https://proxy.example.com");
|
||||
assert.equal(data.node.chatPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH);
|
||||
assert.equal(data.node.modelsPath, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ const proxyFetch = await import("../../open-sse/utils/proxyFetch.ts");
|
||||
const proxyDispatcher = await import("../../open-sse/utils/proxyDispatcher.ts");
|
||||
const proxySettingsRoute = await import("../../src/app/api/settings/proxy/route.ts");
|
||||
const proxyTestRoute = await import("../../src/app/api/settings/proxy/test/route.ts");
|
||||
const shutdownRoute = await import("../../src/app/api/shutdown/route.ts");
|
||||
const restartRoute = await import("../../src/app/api/restart/route.ts");
|
||||
|
||||
async function withEnv(name, value, fn) {
|
||||
const previous = process.env[name];
|
||||
@@ -141,6 +143,80 @@ test(
|
||||
}
|
||||
);
|
||||
|
||||
test("closeDbInstance checkpoints WAL changes into the primary SQLite file", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("checkpoint-test-conn", "openai", "apikey", "checkpoint-test", 1, now, now);
|
||||
|
||||
core.closeDbInstance();
|
||||
|
||||
const snapshotPath = path.join(TEST_DATA_DIR, "storage-snapshot.sqlite");
|
||||
fs.copyFileSync(core.SQLITE_FILE, snapshotPath);
|
||||
|
||||
const Database = (await import("better-sqlite3")).default;
|
||||
const snapshotDb = new Database(snapshotPath, { readonly: true });
|
||||
try {
|
||||
const row = snapshotDb
|
||||
.prepare("SELECT name FROM provider_connections WHERE id = ?")
|
||||
.get("checkpoint-test-conn");
|
||||
assert.equal(row?.name, "checkpoint-test");
|
||||
} finally {
|
||||
snapshotDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("shutdown route uses SIGTERM for graceful shutdown", async () => {
|
||||
const originalKill = process.kill;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const calls = [];
|
||||
|
||||
process.kill = (pid, signal) => {
|
||||
calls.push({ pid, signal });
|
||||
return true;
|
||||
};
|
||||
globalThis.setTimeout = (callback) => {
|
||||
callback();
|
||||
return 0;
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await shutdownRoute.POST();
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(calls, [{ pid: process.pid, signal: "SIGTERM" }]);
|
||||
} finally {
|
||||
process.kill = originalKill;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("restart route uses SIGTERM for graceful restart", async () => {
|
||||
const originalKill = process.kill;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const calls = [];
|
||||
|
||||
process.kill = (pid, signal) => {
|
||||
calls.push({ pid, signal });
|
||||
return true;
|
||||
};
|
||||
globalThis.setTimeout = (callback) => {
|
||||
callback();
|
||||
return 0;
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await restartRoute.POST();
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(calls, [{ pid: process.pid, signal: "SIGTERM" }]);
|
||||
} finally {
|
||||
process.kill = originalKill;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("unlinkFileWithRetry retries EBUSY/EPERM and eventually succeeds", async () => {
|
||||
const target = path.join(TEST_DATA_DIR, "retry-target.tmp");
|
||||
fs.writeFileSync(target, "retry-me");
|
||||
|
||||
@@ -29,14 +29,14 @@ test("model sync route skips success log when fetched models do not change store
|
||||
await resetStorage();
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "MAIN",
|
||||
displayName: "channel-east",
|
||||
displayName: "OpenRouter Main",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
await modelsDb.replaceCustomModels("openai", [
|
||||
await modelsDb.replaceCustomModels("openrouter", [
|
||||
{
|
||||
id: "custom-model-1",
|
||||
name: "Custom Model 1",
|
||||
@@ -73,14 +73,14 @@ test("model sync route skips success log when fetched models do not change store
|
||||
}
|
||||
});
|
||||
|
||||
test("model sync route logs changed model syncs against the current channel label", async () => {
|
||||
test("model sync route stores the real provider while keeping the account label", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
provider: "openrouter",
|
||||
authType: "apikey",
|
||||
name: "MAIN",
|
||||
displayName: "channel-west",
|
||||
displayName: "OpenRouter Main",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
@@ -105,10 +105,12 @@ test("model sync route logs changed model syncs against the current channel labe
|
||||
const body = await response.json();
|
||||
assert.equal(body.logged, true);
|
||||
assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 });
|
||||
assert.equal(body.provider, "openrouter");
|
||||
|
||||
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(logs[0].provider, "channel-west");
|
||||
assert.equal(logs[0].provider, "openrouter");
|
||||
assert.equal(logs[0].account, "MAIN");
|
||||
assert.equal(logs[0].model, "model-sync");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
@@ -369,7 +369,9 @@ test("Chat→Responses streaming: usage-only chunk is captured (not dropped)", (
|
||||
const completedEvent = finishEvents.find((e) => e.event === "response.completed");
|
||||
assert.ok(completedEvent, "should have completed event");
|
||||
assert.ok(completedEvent.data.response.usage, "completed event should include usage");
|
||||
assert.equal(completedEvent.data.response.usage.prompt_tokens, 10);
|
||||
assert.equal(completedEvent.data.response.usage.input_tokens, 10);
|
||||
assert.equal(completedEvent.data.response.usage.output_tokens, 5);
|
||||
assert.equal(completedEvent.data.response.usage.total_tokens, 15);
|
||||
});
|
||||
|
||||
test("Chat→Responses streaming: completed event includes accumulated output", () => {
|
||||
|
||||
Reference in New Issue
Block a user