mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 18:32:16 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff6353b88 | ||
|
|
926fd8abf4 | ||
|
|
211a7a4cfe | ||
|
|
c1835cd9cc | ||
|
|
5700044393 | ||
|
|
36fbd3d018 | ||
|
|
2392006246 | ||
|
|
a6e78cd5dc |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -4,6 +4,37 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.1.2] — 2026-03-26
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Critical: Tool Calling Regression** — Fixed `proxy_Bash` errors by disabling the `proxy_` tool name prefix in the Claude passthrough path. Tools like `Bash`, `Read`, `Write` were being renamed to `proxy_Bash`, `proxy_Read`, etc., causing Claude to reject them (#618)
|
||||
- **Kiro Account Ban Documentation** — Documented as upstream AWS anti-fraud false positive, not an OmniRoute issue (#649)
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **936 tests, 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## [3.1.1] — 2026-03-26
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Vision Capability Metadata**: Added `capabilities.vision`, `input_modalities`, and `output_modalities` to `/v1/models` entries for vision-capable models (PR #646)
|
||||
- **Gemini 3.1 Models**: Added `gemini-3.1-pro-preview` and `gemini-3.1-flash-lite-preview` to the Antigravity provider (#645)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Ollama Cloud 401 Error**: Fixed incorrect API base URL — changed from `api.ollama.com` to official `ollama.com/v1/chat/completions` (#643)
|
||||
- **Expired Token Retry**: Added bounded retry with exponential backoff (5→10→20 min) for expired OAuth connections instead of permanently skipping them (PR #647)
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **936 tests, 0 failures**
|
||||
|
||||
---
|
||||
|
||||
## [3.1.0] — 2026-03-26
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.1.0
|
||||
version: 3.1.2
|
||||
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,
|
||||
|
||||
@@ -393,6 +393,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
|
||||
@@ -1041,8 +1043,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
alias: "ollamacloud",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.ollama.com/v1/chat/completions",
|
||||
modelsUrl: "https://api.ollama.com/v1/models",
|
||||
baseUrl: "https://ollama.com/v1/chat/completions",
|
||||
modelsUrl: "https://ollama.com/api/tags",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
// Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro).
|
||||
|
||||
@@ -426,10 +426,12 @@ export async function handleChatCore({
|
||||
} else {
|
||||
translatedBody = { ...body };
|
||||
|
||||
// Issue #199: Disable tool name prefix when routing Claude-format requests
|
||||
// to non-Claude backends (prefix causes tool name mismatches)
|
||||
const claudeProviders = ["claude", "anthropic"];
|
||||
if (targetFormat === FORMATS.CLAUDE && !claudeProviders.includes(provider?.toLowerCase?.())) {
|
||||
// Issue #199 + #618: Always disable tool name prefix in Claude passthrough.
|
||||
// The proxy_ prefix was designed for OpenAI→Claude translation to avoid
|
||||
// conflicts with Claude OAuth tools, but in the passthrough path the tools
|
||||
// are already in Claude format. Applying the prefix turns "Bash" into
|
||||
// "proxy_Bash", which Claude rejects ("No such tool available: proxy_Bash").
|
||||
if (targetFormat === FORMATS.CLAUDE) {
|
||||
translatedBody._disableToolPrefix = true;
|
||||
}
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.2",
|
||||
"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": {
|
||||
|
||||
@@ -33,6 +33,47 @@ const FALLBACK_ALIAS_TO_PROVIDER = {
|
||||
qw: "qwen",
|
||||
};
|
||||
|
||||
const VISION_MODEL_KEYWORDS = [
|
||||
"gpt-4o",
|
||||
"gpt-4.1",
|
||||
"gpt-4-vision",
|
||||
"gpt-4-turbo",
|
||||
"claude-3",
|
||||
"claude-3.5",
|
||||
"claude-3-5",
|
||||
"claude-4",
|
||||
"claude-opus",
|
||||
"claude-sonnet",
|
||||
"claude-haiku",
|
||||
"gemini",
|
||||
"gemma",
|
||||
"llava",
|
||||
"bakllava",
|
||||
"pixtral",
|
||||
"mistral-pixtral",
|
||||
"qwen-vl",
|
||||
"qvq",
|
||||
"glm-4.6v",
|
||||
"glm-4.5v",
|
||||
"vision",
|
||||
"multimodal",
|
||||
];
|
||||
|
||||
function isVisionModelId(modelId: string): boolean {
|
||||
const normalized = String(modelId || "").toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return VISION_MODEL_KEYWORDS.some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
function getVisionCapabilityFields(modelId: string) {
|
||||
if (!isVisionModelId(modelId)) return null;
|
||||
return {
|
||||
capabilities: { vision: true },
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAliasMaps() {
|
||||
const aliasToProviderId: Record<string, string> = {};
|
||||
const providerIdToAlias: Record<string, string> = {};
|
||||
@@ -214,6 +255,8 @@ export async function getUnifiedModelsResponse(
|
||||
|
||||
for (const model of providerModels) {
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
const visionFields =
|
||||
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
|
||||
// Model-level context length overrides provider default
|
||||
const contextLength = model.contextLength || defaultContextLength;
|
||||
|
||||
@@ -226,13 +269,17 @@ export async function getUnifiedModelsResponse(
|
||||
root: model.id,
|
||||
parent: null,
|
||||
...(contextLength ? { context_length: contextLength } : {}),
|
||||
...(visionFields || {}),
|
||||
});
|
||||
|
||||
// Add provider-id prefix in addition to short alias (ex: kiro/model + kr/model).
|
||||
// This improves compatibility for clients that expect full provider names.
|
||||
if (canonicalProviderId !== alias) {
|
||||
const providerIdModel = `${canonicalProviderId}/${model.id}`;
|
||||
const providerVisionFields =
|
||||
getVisionCapabilityFields(providerIdModel) || getVisionCapabilityFields(model.id);
|
||||
models.push({
|
||||
id: `${canonicalProviderId}/${model.id}`,
|
||||
id: providerIdModel,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
@@ -240,6 +287,7 @@ export async function getUnifiedModelsResponse(
|
||||
root: model.id,
|
||||
parent: aliasId,
|
||||
...(contextLength ? { context_length: contextLength } : {}),
|
||||
...(providerVisionFields || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -383,6 +431,10 @@ export async function getUnifiedModelsResponse(
|
||||
if (endpoints.includes("embeddings")) modelType = "embedding";
|
||||
else if (endpoints.includes("images")) modelType = "image";
|
||||
else if (endpoints.includes("audio")) modelType = "audio";
|
||||
const visionFields =
|
||||
modelType === "chat"
|
||||
? getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId)
|
||||
: null;
|
||||
|
||||
models.push({
|
||||
id: aliasId,
|
||||
@@ -398,12 +450,18 @@ export async function getUnifiedModelsResponse(
|
||||
...(endpoints.length > 1 || !endpoints.includes("chat")
|
||||
? { supported_endpoints: endpoints }
|
||||
: {}),
|
||||
...(visionFields || {}),
|
||||
});
|
||||
|
||||
// Only add provider-prefixed version if different from alias
|
||||
if (canonicalProviderId !== alias && !prefix) {
|
||||
const providerPrefixedId = `${canonicalProviderId}/${modelId}`;
|
||||
if (models.some((m) => m.id === providerPrefixedId)) continue;
|
||||
const providerVisionFields =
|
||||
modelType === "chat"
|
||||
? getVisionCapabilityFields(providerPrefixedId) ||
|
||||
getVisionCapabilityFields(modelId)
|
||||
: null;
|
||||
models.push({
|
||||
id: providerPrefixedId,
|
||||
object: "model",
|
||||
@@ -414,6 +472,7 @@ export async function getUnifiedModelsResponse(
|
||||
parent: aliasId,
|
||||
custom: true,
|
||||
...(modelType ? { type: modelType } : {}),
|
||||
...(providerVisionFields || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
const TICK_MS = 60 * 1000; // sweep interval: every 60 seconds
|
||||
const DEFAULT_HEALTH_CHECK_INTERVAL_MIN = 60; // default per-connection interval
|
||||
const EXPIRED_RETRY_MAX = 3; // max retry attempts for expired connections before giving up
|
||||
const EXPIRED_RETRY_BACKOFF_MIN = 5; // backoff between expired retries (minutes)
|
||||
const LOG_PREFIX = "[HealthCheck]";
|
||||
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
@@ -172,8 +174,19 @@ async function checkConnection(conn) {
|
||||
if (!conn.isActive) return;
|
||||
if (!conn.refreshToken || typeof conn.refreshToken !== "string") return;
|
||||
|
||||
// Skip connections already marked as expired (need re-auth, not retry)
|
||||
if (conn.testStatus === "expired") return;
|
||||
// Retry expired connections with exponential backoff up to EXPIRED_RETRY_MAX times.
|
||||
if (conn.testStatus === "expired") {
|
||||
const retryCount = conn.expiredRetryCount ?? 0;
|
||||
if (retryCount >= EXPIRED_RETRY_MAX) return;
|
||||
|
||||
const lastRetry = conn.expiredRetryAt ? new Date(conn.expiredRetryAt).getTime() : 0;
|
||||
const backoffMs = EXPIRED_RETRY_BACKOFF_MIN * 60 * 1000 * Math.pow(2, retryCount);
|
||||
if (Date.now() - lastRetry < backoffMs) return;
|
||||
|
||||
log(
|
||||
`${LOG_PREFIX} Retrying expired ${conn.provider}/${conn.name || conn.email || conn.id} (attempt ${retryCount + 1}/${EXPIRED_RETRY_MAX})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!supportsTokenRefresh(conn.provider)) {
|
||||
const now = new Date().toISOString();
|
||||
@@ -248,7 +261,6 @@ async function checkConnection(conn) {
|
||||
}
|
||||
|
||||
if (result && result.accessToken) {
|
||||
// Token refreshed successfully — update DB
|
||||
const updateData: any = {
|
||||
accessToken: result.accessToken,
|
||||
lastHealthCheckAt: now,
|
||||
@@ -258,6 +270,8 @@ async function checkConnection(conn) {
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
errorCode: null,
|
||||
expiredRetryCount: null,
|
||||
expiredRetryAt: null,
|
||||
};
|
||||
|
||||
if (result.refreshToken) {
|
||||
@@ -271,18 +285,22 @@ async function checkConnection(conn) {
|
||||
await updateProviderConnection(conn.id, updateData);
|
||||
log(`${LOG_PREFIX} ✓ ${conn.provider}/${conn.name || conn.email || conn.id} refreshed`);
|
||||
} else {
|
||||
// Refresh failed — record but don't disable the connection
|
||||
const wasExpired = conn.testStatus === "expired";
|
||||
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
|
||||
|
||||
await updateProviderConnection(conn.id, {
|
||||
lastHealthCheckAt: now,
|
||||
testStatus: "error",
|
||||
testStatus: wasExpired ? "expired" : "error",
|
||||
lastError: "Health check: token refresh failed",
|
||||
lastErrorAt: now,
|
||||
lastErrorType: "token_refresh_failed",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
|
||||
});
|
||||
logWarn(
|
||||
`${LOG_PREFIX} ✗ ${conn.provider}/${conn.name || conn.email || conn.id} refresh failed`
|
||||
`${LOG_PREFIX} ✗ ${conn.provider}/${conn.name || conn.email || conn.id} refresh failed` +
|
||||
(wasExpired ? ` (expired retry ${retryCount}/${EXPIRED_RETRY_MAX})` : "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user