Compare commits

...

24 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza f279368531 Merge pull request #911 from diegosouzapw/release/v3.4.4
Build Electron Desktop App / Validate version (push) Failing after 33s
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
chore(release): v3.4.4 — Responses API token fix, SQLite WAL checkpoint, issue triage
2026-04-02 07:44:29 -03:00
diegosouzapw 4cc44b37bb chore(release): v3.4.4 — Responses API token fix, SQLite WAL checkpoint, issue triage 2026-04-02 01:05:09 -03:00
Diego Rodrigues de Sa e Souza e121fec599 Merge pull request #905 from rdself/coder/sqlite-wal-checkpoint-shutdown
Flush SQLite WAL on graceful shutdown
2026-04-02 01:00:50 -03:00
Diego Rodrigues de Sa e Souza 6c669abb23 Merge pull request #909 from christopher-s/fix/responses-api-flush-total-tokens
fix(translator): emit response.completed with total_tokens for Responses API clients
2026-04-02 01:00:34 -03:00
Diego Rodrigues de Sa e Souza 0b677677d1 Merge pull request #910 from diegosouzapw/release/v3.4.3
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
Build Electron Desktop App / Publish to npm (push) Has been skipped
chore(release): v3.4.3 — Antigravity memory/skills, Claude Code bridge, Qwen OAuth, model sync stability
2026-04-02 00:15:39 -03:00
diegosouzapw 49f9fee446 chore(release): v3.4.3 — Antigravity memory/skills, Claude Code bridge, Qwen OAuth fix, model sync stability 2026-04-02 00:12:16 -03:00
Chris Staley 9588c1ea3e refactor(translator): extract usage token constants for readability
Extract input_tokens/output_tokens into local constants to avoid
repeating nullish coalescing chains in the total_tokens calculation.
2026-04-01 20:03:31 -06:00
diegosouzapw c665b01e89 test: add usage service and response translator test scripts 2026-04-01 22:48:34 -03:00
diegosouzapw 5c2db0134f chore(release): update changelog for v3.4.4 2026-04-01 22:48:34 -03:00
R.D. de162eb719 fix: drop assistant prefill for cc bridge 2026-04-01 22:48:34 -03:00
R.D. 33297e0226 fix: repair cc compatible v1 route handling 2026-04-01 22:48:34 -03:00
R.D. a07e643020 fix(model-sync): store real provider in log summary 2026-04-01 22:48:34 -03:00
Chris Staley 304664b318 test: update usage field assertions to Responses API format
The normalization to input_tokens/output_tokens/total_tokens changed
the usage field names. Update test to assert on the correct fields.
2026-04-01 19:46:18 -06:00
Chris Staley 8372a3c7ca fix(translator): emit response.completed with total_tokens for Responses API clients
Hub-and-spoke flush path was broken for non-OpenAI providers when the
client speaks OpenAI Responses API (e.g. Codex CLI). When
claudeToOpenAIResponse(null) returned null, openaiToOpenAIResponsesResponse
was never called, so response.completed (carrying total_tokens) was never
emitted — causing "missing field total_tokens" errors in Codex.

Two fixes:
- Pass null through to Step 2 translator even when Step 1 produced no
  output during flush, so terminal events get emitted
- Capture usage from any chunk carrying it (not just usage-only chunks)
  and normalize Chat Completions format to Responses API format
2026-04-01 19:26:34 -06:00
R.D. 69bbc0a2a1 flush sqlite wal on graceful shutdown 2026-04-01 20:14:31 -04:00
diegosouzapw 5bfe881fc8 chore(release): bump version to 3.4.4 and apply PR 900 (Qwen OAuth resource URL) 2026-04-01 21:07:37 -03:00
diegosouzapw 44f691bea4 chore(changelog): include PRs #873, #869, #899 in v3.4.2 2026-04-01 20:47:30 -03:00
tombii e59db45f5b refactor(model-sync): move empty-list guard to early return
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:47:30 -03:00
tombii f4087694b1 fix(model-sync): skip replace when auto-sync returns empty model list
Prevent auto-sync from wiping manually-imported models when the upstream
/models endpoint fails, times out, or returns an empty list. Added
`allowEmpty` option (default false) to replaceCustomModels — callers that
intentionally clear all models (DELETE ?all=true) pass `allowEmpty: true`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:47:30 -03:00
zenobit 2c63e0fdd6 chore(ci): improve and show CI summary 2026-04-01 20:47:30 -03:00
zenobit 29bb71373e fix(ci): add missing dependencies for build
- prop-types: required by 12 component files using PropTypes
- js-yaml: required by openapi spec route

These dependencies were missing from package.json causing build failures.
2026-04-01 20:47:30 -03:00
zenobit ed48858635 Apply suggestions from code review
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 20:47:30 -03:00
zenobit 6f5c8389eb feat(i18n): add windsurf guide steps to all 33 languages
Added missing cliTools.guides.windsurf.steps[1-5] with title and desc:
- step 1: Open AI Settings
- step 2: Add Custom Provider
- step 3: Base URL (http://127.0.0.1:20128/v1)
- step 4: API Key
- step 5: Select Model

Total: 165 keys across all language files (5 steps × 2 keys × 33 languages)
2026-04-01 20:46:44 -03:00
diegosouzapw 52bd72f449 chore: align version strings to v3.4.3 2026-04-01 20:43:33 -03:00
39 changed files with 592 additions and 125 deletions
+112 -25
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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):**
+1
View File
@@ -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
+1
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.4.2",
"version": "3.4.4",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
+2 -2
View File
@@ -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
+9 -2
View File
@@ -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;
}
+3 -3
View File
@@ -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 -1
View File
@@ -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",
+55 -7
View File
@@ -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)
+2 -3
View File
@@ -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);
}
+4 -1
View File
@@ -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(() => "");
+9
View File
@@ -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 [];
}
+3 -16
View File
@@ -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
View File
@@ -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,
+18 -2
View File
@@ -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> = {
+12 -3
View File
@@ -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:
+8 -1
View File
@@ -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,
},
+22 -12
View File
@@ -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 });
+2 -2
View File
@@ -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" });
+1 -1
View File
@@ -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;
+24
View File
@@ -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
View File
@@ -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 ────────────────
+3 -5
View File
@@ -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);
+1 -1
View File
@@ -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,
+9 -3
View File
@@ -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),
+43
View File
@@ -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;
}
});
+9
View File
@@ -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
)
);
+50 -7
View File
@@ -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);
});
+76
View File
@@ -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");
+9 -7
View File
@@ -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", () => {