Compare commits

...

14 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza 8dae4e5038 Merge pull request #629 from diegosouzapw/release/v3.0.7
Build Electron Desktop App / Validate version (push) Failing after 23s
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.0.7 — Antigravity token fix, Playground selector, CLI models
2026-03-25 19:30:06 -03:00
diegosouzapw b9b28edefe chore(release): v3.0.7 — Antigravity token fix, Playground selector, CLI models
Bug Fixes:
- Antigravity token refresh clientSecret (#588)
- OpenCode Zen modelsUrl (#612)
- Streaming artifacts newline collapse (#626)
- Proxy fallback and test credential resolution

Features:
- Playground persistent Account/Key selector
- CLI Tools dynamic model listing
- Antigravity model list update + passthroughModels (#628)
2026-03-25 19:27:40 -03:00
diegosouzapw 58120f435f Merge feat/issue-628: Update Antigravity model list + passthroughModels (#628) 2026-03-25 19:24:16 -03:00
diegosouzapw 027b8e52da Merge fix/issue-588-612: Antigravity clientSecret + OpenCode Zen modelsUrl (#588, #612) 2026-03-25 19:24:07 -03:00
diegosouzapw aad510a9d5 feat: update Antigravity model list and enable passthrough (#628)
- Add Claude Sonnet 4.5, Claude Sonnet 4, GPT 5, GPT 5 Mini
- Enable passthroughModels: true so users can access any model
  Antigravity supports without waiting for registry updates
2026-03-25 19:18:00 -03:00
diegosouzapw 9852a805a1 fix: Antigravity token refresh clientSecret and OpenCode Zen modelsUrl (#588, #612)
- Set clientSecretDefault for Antigravity provider (was empty, causing
  'client_secret is missing' on token refresh for npm users)
- Add modelsUrl to opencode-zen registry for 'Import from /models'
2026-03-25 19:13:29 -03:00
diegosouzapw b2cabf0122 feat(playground): add persistent Account/Key selector
Rewrote the account selector with a simpler, reliable approach:
- Fetch ALL connections once at startup (not per-provider)
- Filter by selectedProvider using ALIAS_TO_ID mapping
- Account/Key dropdown always visible when provider selected
- Shows 'Auto (N accounts)' default or individual account names
- Works for both OAuth accounts and API key providers
2026-03-25 19:00:13 -03:00
diegosouzapw 521ce15f86 fix(playground): resolve provider alias-to-ID for account selector
Import ALIAS_TO_ID mapping and resolve provider aliases (cx→codex,
kr→kiro, etc.) in loadConnections before filtering connections from
the API. The /v1/models endpoint returns alias-prefixed model IDs
but /api/providers/client returns provider IDs.
2026-03-25 18:54:49 -03:00
diegosouzapw fb97c11140 feat(dashboard): fix Playground account selector & CLI Tools dynamic model listing
Playground:
- loadConnections() was parsing wrong API response shape (expected
  providers[].connections[] but API returns flat connections[])
- Account selector now shows for any provider with ≥1 connection
- Uses conn.email as name fallback for OAuth providers

CLI Tools:
- getAllAvailableModels() now also fetches from /v1/models API
- Dynamic models supplement static PROVIDER_MODELS definitions
- Fixes providers like Kiro, OpenCode Zen showing 0 models
2026-03-25 18:17:48 -03:00
diegosouzapw 1c5c62e311 fix(streaming): collapse excessive newlines after thinking tag removal (#626)
After stripping <antThinking>/<thinking> tags from streaming responses, the
surrounding newlines were left as artifacts (e.g. \n\n\n\n). Now collapses 3+
consecutive newlines to double-newline after any tag removal.

Also fixes PR #625 merge (Provider Limits light mode background).
2026-03-25 18:10:19 -03:00
diegosouzapw 77148f7f97 Merge pull request #625 from rdself/fix/provider-limits-light-mode-bg
fix: Provider Limits table background in light mode
2026-03-25 18:05:22 -03:00
diegosouzapw a329d2f2bc fix(proxy): test endpoint resolves real credentials from DB via proxyId
The proxy test button in Settings was always failing with 'Socks5 Authentication
failed' because the frontend sent redacted credentials (***) from listProxies().
The backend received '***' as the password and tried to authenticate with it.

Fix: Frontend now sends proxyId in the test request body. The test endpoint
looks up the proxy from the DB with includeSecrets: true and uses the real
stored credentials for the SOCKS5 handshake.

Also: removed username/password from the frontend test payload since they
are always redacted and useless for testing.
2026-03-25 17:54:19 -03:00
diegosouzapw 39e9e4446b fix(usage): proxy fallback — retry without proxy when SOCKS5 relay fails
Root cause: SOCKS5 proxies accept TCP connections (pass health check) but
can't relay HTTPS traffic. getCodexUsage() catches fetch errors internally
and returns {message: 'Failed to fetch...'} instead of throwing, so the
previous catch-based fallback never triggered.

Fix: After the initial proxied fetch, check the returned usage object for
network error indicators. If a proxy was active and the result contains
'fetch failed' / 'ECONNREFUSED' / etc., retry the entire operation
(credential refresh + usage fetch) without proxy context.

This is safe because usage fetching is read-only — showing limits data
without proxy is better than showing nothing.
2026-03-25 17:20:25 -03:00
R.D. b32de54944 fix: use bg-surface for Provider Limits table to match Card components in light mode
bg-bg-subtle (#f0f0f5) appears gray against the page background in
light mode. Changed to bg-surface (#ffffff) for consistency with other
Card-based UI sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:46:28 -04:00
12 changed files with 224 additions and 85 deletions
+22
View File
@@ -4,6 +4,28 @@
---
## [3.0.7] — 2026-03-25
### 🐛 Bug Fixes
- **Antigravity Token Refresh:** Fixed `client_secret is missing` error for npm-installed users — the `clientSecretDefault` was empty in providerRegistry, causing Google to reject token refresh requests (#588)
- **OpenCode Zen Models:** Added `modelsUrl` to the OpenCode Zen registry entry so "Import from /models" works correctly (#612)
- **Streaming Artifacts:** Fixed excessive newlines left in responses after thinking-tag signature stripping (#626)
- **Proxy Fallback:** Added automatic retry without proxy when SOCKS5 relay fails
- **Proxy Test:** Test endpoint now resolves real credentials from DB via proxyId
### ✨ New Features
- **Playground Account/Key Selector:** Persistent, always-visible dropdown to select specific provider accounts/keys for testing — fetches all connections at startup and filters by selected provider
- **CLI Tools Dynamic Models:** Model selection now dynamically fetches from `/v1/models` API — providers like Kiro now show their full model catalog
- **Antigravity Model List:** Updated with Claude Sonnet 4.5, Claude Sonnet 4, GPT 5, GPT 5 Mini; enabled `passthroughModels` for dynamic model access (#628)
### 🔧 Maintenance
- Merged PR #625 — Provider Limits light mode background fix
---
## [3.0.6] — 2026-03-25
### 🐛 Bug Fixes
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.0.6
version: 3.0.7
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,
+7 -1
View File
@@ -386,16 +386,21 @@ export const REGISTRY: Record<string, RegistryEntry> = {
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
clientIdDefault: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecretEnv: "ANTIGRAVITY_OAUTH_CLIENT_SECRET",
clientSecretDefault: "",
clientSecretDefault: "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",
},
models: [
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" },
{ 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-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" },
{ id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" },
{ id: "gpt-5", name: "GPT 5" },
{ id: "gpt-5-mini", name: "GPT 5 Mini" },
],
passthroughModels: true,
},
github: {
@@ -576,6 +581,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
format: "openai",
executor: "opencode",
baseUrl: "https://opencode.ai/zen/v1",
modelsUrl: "https://opencode.ai/zen/v1/models",
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer",
+5
View File
@@ -132,6 +132,11 @@ export function detectAndLearn(
}
}
// Collapse excessive consecutive newlines left after tag removal (fixes #626)
if (found.length > 0) {
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
}
return { found, cleaned: cleaned.trim() || cleaned };
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.6",
"version": "3.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.6",
"version": "3.0.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.6",
"version": "3.0.7",
"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,7 @@ export default function CLIToolsPageClient({ machineId }) {
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
const [dynamicModels, setDynamicModels] = useState([]);
const translateOrFallback = useCallback(
(key, fallback, values = undefined) => {
try {
@@ -49,6 +50,7 @@ export default function CLIToolsPageClient({ machineId }) {
loadCloudSettings();
fetchApiKeys();
fetchToolStatuses();
fetchDynamicModels();
}, []);
const loadCloudSettings = async () => {
@@ -107,6 +109,18 @@ export default function CLIToolsPageClient({ machineId }) {
}
};
const fetchDynamicModels = async () => {
try {
const res = await fetch("/v1/models");
if (res.ok) {
const data = await res.json();
setDynamicModels(data?.data || []);
}
} catch (error) {
console.log("Error fetching dynamic models:", error);
}
};
const getActiveProviders = () => {
return connections.filter((c) => c.isActive !== false);
};
@@ -116,6 +130,7 @@ export default function CLIToolsPageClient({ machineId }) {
const models = [];
const seenModels = new Set();
// First: add static models from the constants
activeProviders.forEach((conn) => {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
const providerModels = getModelsByProviderId(conn.provider);
@@ -135,6 +150,31 @@ export default function CLIToolsPageClient({ machineId }) {
});
});
// Second: add dynamic models from /v1/models (fills gaps for Kiro, OpenCode, custom providers)
const activeProviderIds = new Set(activeProviders.map((c) => c.provider));
const activeAliases = new Set(
activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider)
);
dynamicModels.forEach((dm) => {
const modelId = dm.id || dm;
if (seenModels.has(modelId)) return;
// Parse alias/model format
const slashIdx = modelId.indexOf("/");
if (slashIdx === -1) return;
const alias = modelId.substring(0, slashIdx);
const bareModel = modelId.substring(slashIdx + 1);
if (!activeAliases.has(alias) && !activeProviderIds.has(alias)) return;
seenModels.add(modelId);
models.push({
value: modelId,
label: modelId,
provider: alias,
alias: alias,
connectionName: "",
modelId: bareModel,
});
});
return models;
};
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Card, Button, Select, Badge } from "@/shared/components";
import { ALIAS_TO_ID } from "@/shared/constants/providers";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
@@ -189,7 +190,7 @@ function ImageResultsInline({ data }: { data: any }) {
export default function PlaygroundPage() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
const [connections, setConnections] = useState<ConnectionOption[]>([]);
const [allConnections, setAllConnections] = useState<ConnectionOption[]>([]);
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [selectedConnection, setSelectedConnection] = useState("");
@@ -214,39 +215,16 @@ export default function PlaygroundPage() {
const isImageEndpoint = selectedEndpoint === "images";
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
// Load connections for a given provider (called imperatively)
const loadConnections = useCallback((provider: string) => {
if (!provider) {
setConnections([]);
setSelectedConnection("");
return;
}
fetch("/api/providers/client")
.then((res) => res.json())
.then((data) => {
const allConns: ConnectionOption[] = [];
for (const p of data?.providers || []) {
if (p.id !== provider) continue;
for (const conn of p.connections || []) {
allConns.push({
id: conn.id,
name: conn.name || conn.id,
provider: p.id,
authType: conn.authType || "apiKey",
});
}
}
setConnections(allConns);
setSelectedConnection("");
})
.catch(() => {
setConnections([]);
setSelectedConnection("");
});
}, []);
// Load connections for a given provider — filtered from allConnections
const providerConnections = allConnections.filter((c) => {
if (!selectedProvider) return false;
const resolvedProvider = ALIAS_TO_ID[selectedProvider] || selectedProvider;
return c.provider === resolvedProvider || c.provider === selectedProvider;
});
// Fetch models and initialize first provider
// Fetch models and ALL connections at startup
useEffect(() => {
// Fetch models
fetch("/v1/models")
.then((res) => res.json())
.then((data) => {
@@ -264,11 +242,27 @@ export default function PlaygroundPage() {
setProviders(providerOpts);
if (providerOpts.length > 0) {
setSelectedProvider(providerOpts[0].value);
loadConnections(providerOpts[0].value);
}
})
.catch(() => {});
}, [loadConnections]);
// Fetch ALL connections (once)
fetch("/api/providers/client")
.then((res) => res.json())
.then((data) => {
const conns: ConnectionOption[] = [];
for (const conn of data?.connections || []) {
conns.push({
id: conn.id,
name: conn.name || conn.email || conn.id,
provider: conn.provider,
authType: conn.authType || "apiKey",
});
}
setAllConnections(conns);
})
.catch(() => {});
}, []);
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
@@ -285,7 +279,6 @@ export default function PlaygroundPage() {
const handleProviderChange = (newProvider: string) => {
setSelectedProvider(newProvider);
setSelectedConnection("");
loadConnections(newProvider);
const providerModels = models
.filter((m) => !newProvider || m.id.startsWith(newProvider + "/"))
.map((m) => m.id);
@@ -527,18 +520,24 @@ export default function PlaygroundPage() {
</div>
)}
{/* Account/Connection — shown when provider has multiple connections */}
{!isSearchEndpoint && connections.length > 1 && (
{/* Account/Key — always shown when provider is selected */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Account
Account / Key
</label>
<Select
value={selectedConnection}
onChange={(e: any) => setSelectedConnection(e.target.value)}
options={[
{ value: "", label: `All (${connections.length} accounts)` },
...connections.map((c) => ({
{
value: "",
label:
providerConnections.length > 0
? `Auto (${providerConnections.length} accounts)`
: "No accounts",
},
...providerConnections.map((c) => ({
value: c.id,
label: c.name,
})),
@@ -209,12 +209,11 @@ export default function ProxyRegistryManager() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proxyId: item.id,
proxy: {
type: item.type || "http",
host: item.host,
port: String(item.port || 8080),
username: item.username,
password: item.password,
},
}),
});
@@ -511,7 +511,7 @@ export default function ProviderLimits() {
</div>
{/* Account rows */}
<div className="rounded-xl border border-border overflow-hidden bg-bg-subtle">
<div className="rounded-xl border border-border overflow-hidden bg-surface">
{/* Table header */}
<div
className="items-center px-4 py-2.5 border-b border-border text-[11px] font-semibold uppercase tracking-wider text-text-muted"
+21 -1
View File
@@ -8,6 +8,7 @@ import {
import { testProxySchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { getProxyById } from "@/lib/localDb";
const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]);
@@ -56,7 +57,26 @@ export async function POST(request: Request) {
type: "invalid_request",
});
}
const { proxy } = validation.data;
let { proxy } = validation.data;
// If a proxyId is provided, look up the real (non-redacted) credentials from DB.
// The frontend sends redacted credentials (***) from listProxies(), so we need
// the actual secrets for testing.
const body = rawBody as Record<string, unknown>;
const proxyId = typeof body.proxyId === "string" ? body.proxyId.trim() : null;
if (proxyId) {
const dbProxy = await getProxyById(proxyId, { includeSecrets: true });
if (dbProxy) {
proxy = {
...proxy,
host: proxy.host || dbProxy.host,
port: proxy.port || String(dbProxy.port),
type: proxy.type || dbProxy.type,
username: dbProxy.username,
password: dbProxy.password,
};
}
}
const proxyType = String(proxy.type || "http").toLowerCase();
if (proxyType === "socks5" && !isSocks5ProxyEnabled()) {
+84 -36
View File
@@ -135,50 +135,98 @@ export async function GET(
// so that both credential refresh AND usage fetch go through the proxy.
const proxyInfo = await resolveProxyForConnection(connectionId);
// Wrap BOTH credential refresh and usage fetch inside proxy context.
// Codex accounts behind SOCKS5 proxies need the proxy active during token refresh too.
const { usage, refreshed } = (await runWithProxyContext(proxyInfo?.proxy || null, async () => {
let conn = connection;
let wasRefreshed = false;
// Helper: perform credential refresh + usage fetch
const fetchUsageWithContext = async (proxyConfig: unknown) => {
return runWithProxyContext(proxyConfig, async () => {
let conn = connection;
let wasRefreshed = false;
// Refresh credentials if needed using executor
try {
const result = await refreshAndUpdateCredentials(conn);
conn = result.connection;
wasRefreshed = result.refreshed;
// Refresh credentials if needed using executor
try {
const result = await refreshAndUpdateCredentials(conn);
conn = result.connection;
wasRefreshed = result.refreshed;
// Sync to cloud only if token was refreshed
if (wasRefreshed) {
await syncToCloudIfEnabled();
// Sync to cloud only if token was refreshed
if (wasRefreshed) {
await syncToCloudIfEnabled();
}
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
throw refreshError;
}
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
throw refreshError;
}
// Fetch usage from provider API
const usageData = await getUsageForProvider(conn);
connection = conn; // propagate updated connection for status sync below
return { usage: usageData, refreshed: wasRefreshed };
}).catch((refreshError: any) => {
// If error originated from credential refresh, return 401
if (
refreshError?.message?.includes?.("refresh") ||
refreshError?.message?.includes?.("Credential")
) {
return { __authError: true, message: refreshError.message };
}
throw refreshError;
})) as any;
// Fetch usage from provider API
const usageData = await getUsageForProvider(conn);
connection = conn; // propagate updated connection for status sync below
return { usage: usageData, refreshed: wasRefreshed };
});
};
// Handle auth errors from credential refresh
if (usage?.__authError) {
return Response.json(
{ error: `Credential refresh failed: ${usage.message}` },
{ status: 401 }
// Check if a usage result indicates a network-level error (proxy can't relay)
const isNetworkFailure = (usageResult: any): boolean => {
const msg = usageResult?.usage?.message;
if (typeof msg !== "string") return false;
return (
msg.includes("fetch failed") ||
msg.includes("ECONNREFUSED") ||
msg.includes("ETIMEDOUT") ||
msg.includes("Proxy unreachable") ||
msg.includes("UND_ERR_CONNECT_TIMEOUT")
);
};
let result: any;
const proxyConfig = proxyInfo?.proxy || null;
try {
result = await fetchUsageWithContext(proxyConfig);
} catch (proxyError: any) {
const isAuthError =
proxyError?.message?.includes?.("refresh") || proxyError?.message?.includes?.("Credential");
if (isAuthError) {
return Response.json(
{ error: `Credential refresh failed: ${proxyError.message}` },
{ status: 401 }
);
}
// If proxy was active and it's a network error (thrown), retry without proxy
const isThrownNetworkError =
proxyError?.message === "fetch failed" ||
proxyError?.code === "PROXY_UNREACHABLE" ||
proxyError?.code === "UND_ERR_CONNECT_TIMEOUT" ||
proxyError?.cause?.code === "ECONNREFUSED";
if (proxyConfig && isThrownNetworkError) {
console.warn(
`[Usage API] Proxy fetch threw for ${connectionId}, retrying without proxy:`,
proxyError?.message
);
result = await fetchUsageWithContext(null);
} else {
throw proxyError;
}
}
// If the usage result contains a network error AND a proxy was active,
// retry without proxy. getCodexUsage() catches fetch errors internally
// and returns {message: "Failed to fetch..."} instead of throwing.
if (proxyConfig && isNetworkFailure(result)) {
console.warn(
`[Usage API] Proxy usage returned network error for ${connectionId}, retrying without proxy:`,
result.usage?.message
);
try {
result = await fetchUsageWithContext(null);
} catch (directError: any) {
console.error("[Usage API] Direct fetch also failed:", directError?.message);
throw directError;
}
}
const { usage, refreshed } = result;
// Populate quota cache for quota-aware account selection
if (isRecord(usage?.quotas)) {
setQuotaCache(