Compare commits

...

16 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
0b677677d1 Merge pull request #910 from diegosouzapw/release/v3.4.3
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
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
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
27 changed files with 420 additions and 109 deletions

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

View File

@@ -2,11 +2,9 @@
## [Unreleased]
### 🛠️ 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.
## [3.4.2] - 2026-04-01
## [3.4.3] - 2026-04-02
### ✨ New Features
@@ -17,9 +15,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 +34,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

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.4.2
version: 3.4.3
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,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.4.2",
"version": "3.4.3",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

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

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;
}

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)",
});
}

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.4.2",
"version": "3.4.3",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

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)

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);
}

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(() => "");

19
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.4.2",
"version": "3.4.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.4.2",
"version": "3.4.3",
"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.3"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.4.2",
"version": "3.4.3",
"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"
},

View File

@@ -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 : ""),
});

View File

@@ -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,
});

View File

@@ -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,

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> = {

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:

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,
},

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 });

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": {

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,

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
test-antigrav.mjs Normal file
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
test_translator.mjs Normal file
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
)
);

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);
});

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;