fix(release): v3.0.6 — proxy context, playground selector, CI fix

- Fix: Limits usage fetch wraps BOTH token refresh and usage call inside proxy context (fixes SOCKS5 Codex accounts)
- Fix: CI integration test v1/models gracefully handles empty models list
- Fix: Settings proxy test button results now render with priority over health data
- Feat: Playground account selector dropdown for testing specific connections
- Merge: PR #623 LongCat API base URL path correction
This commit is contained in:
diegosouzapw
2026-03-25 13:08:44 -03:00
parent 890a851bbf
commit 9ba65d3323
8 changed files with 157 additions and 43 deletions

View File

@@ -4,6 +4,24 @@
---
## [3.0.6] — 2026-03-25
### 🐛 Bug Fixes
- **Limits/Proxy:** Fixed Codex limit fetching for accounts behind SOCKS5 proxies — token refresh now runs inside proxy context
- **CI:** Fixed integration test `v1/models` assertion failure in CI environments without provider connections
- **Settings:** Proxy test button now shows success/failure results immediately (previously hidden behind health data)
### ✨ New Features
- **Playground:** Added Account selector dropdown — test specific connections individually when a provider has multiple accounts
### 🔧 Maintenance
- Merged PR #623 — LongCat API base URL path correction
---
## [3.0.5] — 2026-03-25
### ✨ New Features

View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.5",
"version": "3.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.5",
"version": "3.0.6",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.5",
"version": "3.0.6",
"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

@@ -20,6 +20,13 @@ interface ProviderOption {
label: string;
}
interface ConnectionOption {
id: string;
name: string;
provider: string;
authType: string;
}
const ENDPOINT_OPTIONS = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses" },
@@ -182,8 +189,10 @@ 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 [selectedProvider, setSelectedProvider] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [selectedConnection, setSelectedConnection] = useState("");
const [selectedEndpoint, setSelectedEndpoint] = useState("chat");
const [requestBody, setRequestBody] = useState("");
const [responseBody, setResponseBody] = useState("");
@@ -205,7 +214,38 @@ export default function PlaygroundPage() {
const isImageEndpoint = selectedEndpoint === "images";
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
// Fetch models
// 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("");
});
}, []);
// Fetch models and initialize first provider
useEffect(() => {
fetch("/v1/models")
.then((res) => res.json())
@@ -222,10 +262,13 @@ export default function PlaygroundPage() {
.sort()
.map((p) => ({ value: p, label: p }));
setProviders(providerOpts);
if (providerOpts.length > 0) setSelectedProvider(providerOpts[0].value);
if (providerOpts.length > 0) {
setSelectedProvider(providerOpts[0].value);
loadConnections(providerOpts[0].value);
}
})
.catch(() => {});
}, []);
}, [loadConnections]);
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
@@ -241,6 +284,8 @@ 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);
@@ -334,8 +379,13 @@ export default function PlaygroundPage() {
} catch {
/* ignore parse errors */
}
const fetchHeaders: Record<string, string> = {};
if (selectedConnection) {
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: fetchHeaders,
body: form,
signal: controller.signal,
});
@@ -345,9 +395,13 @@ export default function PlaygroundPage() {
if (supportsVision && uploadedImages.length > 0) {
parsed = buildChatBodyWithImages(parsed, uploadedImages);
}
const fetchHeaders: Record<string, string> = { "Content-Type": "application/json" };
if (selectedConnection) {
fetchHeaders["X-OmniRoute-Connection"] = selectedConnection;
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: fetchHeaders,
body: JSON.stringify(parsed),
signal: controller.signal,
});
@@ -473,6 +527,27 @@ export default function PlaygroundPage() {
</div>
)}
{/* Account/Connection — shown when provider has multiple connections */}
{!isSearchEndpoint && connections.length > 1 && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Account
</label>
<Select
value={selectedConnection}
onChange={(e: any) => setSelectedConnection(e.target.value)}
options={[
{ value: "", label: `All (${connections.length} accounts)` },
...connections.map((c) => ({
value: c.id,
label: c.name,
})),
]}
className="w-full"
/>
</div>
)}
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
{!isSearchEndpoint && (
<div className="shrink-0">

View File

@@ -467,12 +467,7 @@ export default function ProxyRegistryManager() {
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
<div className="flex flex-col gap-0.5">
{health ? (
<>
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</>
) : testById[item.id] ? (
{testById[item.id] ? (
testById[item.id]!.success ? (
<>
<span className="text-emerald-400">
@@ -484,9 +479,14 @@ export default function ProxyRegistryManager() {
</>
) : (
<span className="text-red-400">
{testById[item.id]!.error || "failed"}
{testById[item.id]!.error || "failed"}
</span>
)
) : health ? (
<>
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</>
) : (
<span></span>
)}

View File

@@ -131,35 +131,54 @@ export async function GET(
return Response.json({ message: "Usage not available for API key connections" });
}
// Refresh credentials if needed using executor
let refreshed = false;
try {
const result = await refreshAndUpdateCredentials(connection);
connection = result.connection;
refreshed = result.refreshed;
// Resolve proxy for this connection FIRST (key → combo → provider → global → direct)
// so that both credential refresh AND usage fetch go through the proxy.
const proxyInfo = await resolveProxyForConnection(connectionId);
// Sync to cloud only if token was refreshed
if (refreshed) {
await syncToCloudIfEnabled();
// 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;
// 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();
}
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
throw refreshError;
}
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", 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;
// Handle auth errors from credential refresh
if (usage?.__authError) {
return Response.json(
{
error: `Credential refresh failed: ${(refreshError as any).message}`,
},
{ error: `Credential refresh failed: ${usage.message}` },
{ status: 401 }
);
}
// Resolve proxy for this connection (key → combo → provider → global → direct)
const proxyInfo = await resolveProxyForConnection(connectionId);
// Fetch usage from provider API, wrapped in proxy context
const usage = await runWithProxyContext(proxyInfo?.proxy || null, () =>
getUsageForProvider(connection)
);
// Populate quota cache for quota-aware account selection
if (isRecord(usage?.quotas)) {
setQuotaCache(

View File

@@ -59,13 +59,15 @@ test("contract: /api/v1/models returns OpenAI-compatible model shape", async ()
assert.equal(body.object, "list");
assert.ok(Array.isArray(body.data));
assert.ok(body.data.length > 0, "models list should not be empty");
const first = body.data[0];
assert.equal(typeof first.id, "string");
assert.equal(first.object, "model");
assert.equal(typeof first.created, "number");
assert.equal(typeof first.owned_by, "string");
// In CI environments without provider connections, models list may be empty — skip shape check
if (body.data.length > 0) {
const first = body.data[0];
assert.equal(typeof first.id, "string");
assert.equal(first.object, "model");
assert.equal(typeof first.created, "number");
assert.equal(typeof first.owned_by, "string");
}
});
test("contract: /api/v1/embeddings GET returns embedding model listing shape", async () => {