Compare commits

...

8 Commits

Author SHA1 Message Date
diegosouzapw
9ff6353b88 release: v3.1.2 — fix critical tool calling regression (#618)
Changes:
- fix: disable proxy_ tool prefix for Claude passthrough (Bash → proxy_Bash)
- docs: document Kiro account ban as upstream AWS issue (#649)
- docs: update CHANGELOG and OpenAPI spec

Fixes #618, closes #649, closes #615
2026-03-26 19:49:45 -03:00
diegosouzapw
926fd8abf4 fix: disable proxy_ tool prefix for all Claude-target passthrough (#618)
The openai-to-claude translator was prefixing tool names with 'proxy_'
(e.g. Bash → proxy_Bash) even when routing Claude-format requests to
native Claude/Anthropic providers. Claude rejects unknown tool names,
causing 'No such tool available: proxy_Bash' errors.

Root cause: the _disableToolPrefix condition only disabled the prefix
for non-Claude providers, but it should be disabled for ALL providers
in the Claude passthrough path since tools are already in Claude format.

Fixes #618
2026-03-26 19:44:44 -03:00
diegosouzapw
211a7a4cfe release: v3.1.1 — Ollama Cloud fix, Gemini 3.1, vision metadata, token retry
Changes:
- fix: Ollama Cloud 401 — wrong base URL (api.ollama.com → ollama.com) (#643)
- fix: Add Gemini 3.1 Pro/Flash to Antigravity provider (#645)
- feat: Vision capability metadata in /v1/models (PR #646)
- feat: Exponential backoff retry for expired OAuth tokens (PR #647)

Closes #643, closes #645
2026-03-26 15:56:44 -03:00
diegosouzapw
c1835cd9cc fix: correct Ollama Cloud URL and add Gemini 3.1 to Antigravity (#643, #645)
- Fix Ollama Cloud base URL from api.ollama.com to ollama.com/v1/chat/completions
- Fix Ollama Cloud models URL to ollama.com/api/tags
- Add gemini-3.1-pro-preview and gemini-3.1-flash-lite-preview to Antigravity provider

Closes #643, closes #645
2026-03-26 15:53:31 -03:00
Diego Rodrigues de Sa e Souza
5700044393 Merge pull request #646 from brendandebeasi/feat/vision-capability-metadata
Thanks @brendandebeasi for another great contribution! 🎉 Vision capability metadata fixes real client compat issues. Merged for v3.1.1.
2026-03-26 15:49:54 -03:00
Diego Rodrigues de Sa e Souza
36fbd3d018 Merge pull request #647 from brendandebeasi/fix/expired-token-retry-healthcheck
Thanks @brendandebeasi for this excellent contribution! 🎉 The bounded retry with exponential backoff is exactly the right approach for expired connections. Merged and will be included in v3.1.1.
2026-03-26 15:49:52 -03:00
Brendan DeBeasi
2392006246 fix: retry expired connections in token health check instead of permanently skipping
Connections marked as 'expired' were permanently skipped by the health check scheduler (line 176: if testStatus === "expired" return). A single transient refresh failure could permanently disable auto-refresh, requiring manual re-authentication.

Replace the hard skip with a bounded retry mechanism: up to 3 attempts with exponential backoff (5min, 10min, 20min). On success, the connection is fully restored to active. On exhaustion, it remains expired (same as before). The existing circuit breaker (5 failures → 30min pause) provides additional protection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:11:49 -07:00
Brendan DeBeasi
a6e78cd5dc feat: add vision capability metadata to /v1/models response
OpenAI-compatible clients (OpenCode, etc.) check capabilities/input_modalities fields on the /v1/models response to determine if a model supports image input. Omniroute was not emitting these fields, causing clients to assume text-only for all models routed through the proxy.

Add keyword-based vision detection (matching the existing playground heuristic) that annotates model entries with capabilities:{vision:true}, input_modalities:["text","image"], and output_modalities:["text"] for known multimodal models (GPT-4o/4-turbo, Claude 3+, Gemini, Pixtral, Qwen-VL, etc.).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:00:29 -07:00
8 changed files with 129 additions and 17 deletions

View File

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

View File

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

View File

@@ -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).

View File

@@ -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
View File

@@ -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": [

View File

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

View File

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

View File

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