Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dae4e5038 | |||
| b9b28edefe | |||
| 58120f435f | |||
| 027b8e52da | |||
| aad510a9d5 | |||
| 9852a805a1 | |||
| b2cabf0122 | |||
| 521ce15f86 | |||
| fb97c11140 | |||
| 1c5c62e311 | |||
| 77148f7f97 | |||
| a329d2f2bc | |||
| 39e9e4446b | |||
| b32de54944 |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user