Release v3.7.4 (#1730)

* chore(release): v3.7.4 — version bump, openapi and changelog sync

* fix: preserve previous_response_id and conversation_id fields on empty input array (#1729)

* fix: bypass UI validation block for optional API keys and fix string fallback typing (#1721)

* fix(proxy): disable HTTP keep-alive and pipelining in Undici proxy dispatcher to prevent socket hang up

* feat(proxy): implement bulk proxy import via pipe-delimited parser with update-or-create logic

* docs: update changelog for v3.7.4 fixes and proxy features

* test: update responses store expectations for empty input arrays

* feat(pwa): add fullscreen installable PWA with manifest, service worker, and cross-platform app icons. (#1728)

Integrated into release/v3.7.4

* Fix image provider validation and Stability image requests (#1726)

Integrated into release/v3.7.4

* docs: add PR 1726 and PR 1728 to v3.7.4 changelog

* fix(security): replace insecure Math.random with crypto.getRandomValues for fallback UUID generation

* fix(migrations): intercept 007 migration to use IF NOT EXISTS logic on fresh installs

Fixes #1733

* test: fix typescript compilation errors in unit tests

* fix(db): reconcile legacy reasoning cache migration

* chore(release): bump to v3.7.4 — changelog, docs, version sync

* fix(cc-compatible): preserve Claude Code system skeleton (#1740)

Integrated into release/v3.7.4

* docs(changelog): update for PR #1740 merge

* docs(changelog): include workflow updates

* fix(db): reconcile legacy reasoning cache migration (#1734)

Integrated into release/v3.7.4

* Add endpoint tunnel visibility settings (#1743)

Integrated into release/v3.7.4

* Normalize max reasoning effort for Codex routing (#1744)

Integrated into release/v3.7.4

* Fix Claude Code gateway config helper (#1745)

Integrated into release/v3.7.4

* Refresh CLI fingerprint provider profiles (#1746)

Integrated into release/v3.7.4

* Integrated into release/v3.7.4 (PR #1742)

* docs(changelog): update for PRs 1742-1746

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Yash Ghule <y.ghule77@gmail.com>
Co-authored-by: backryun <bakryun0718@proton.me>
Co-authored-by: dhaern <manker_lol@hotmail.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Duncan L <leungd@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-28 20:46:25 -03:00
committed by GitHub
parent 4cdd0dfd1a
commit 0cd388efb8
160 changed files with 4957 additions and 684 deletions

View File

@@ -12,6 +12,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
import { copyToClipboard } from "@/shared/utils/clipboard";
import type { NewsAnnouncement } from "@/shared/utils/releaseNotes";
type UpdateStep = {
step: string;
@@ -19,6 +20,16 @@ type UpdateStep = {
message: string;
};
type VersionInfo = {
current: string;
latest: string;
updateAvailable: boolean;
channel: string;
autoUpdateSupported: boolean;
autoUpdateError?: string | null;
news?: NewsAnnouncement | null;
};
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) {
@@ -43,7 +54,7 @@ export default function HomePageClient({ machineId }) {
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerMetrics, setProviderMetrics] = useState({});
const [versionInfo, setVersionInfo] = useState<any>(null);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [updating, setUpdating] = useState(false);
const [updateSteps, setUpdateSteps] = useState<UpdateStep[]>([]);
const [updatePhase, setUpdatePhase] = useState<"idle" | "running" | "done" | "failed">("idle");
@@ -540,29 +551,64 @@ export default function HomePageClient({ machineId }) {
{/* Update Notification Banner */}
{versionInfo?.updateAvailable && !showUpdateOverlay && (
<div className="bg-primary/10 border border-primary/20 text-primary px-5 py-4 rounded-xl flex items-center justify-between min-h-[64px]">
<div className="flex items-center gap-4">
<span className="material-symbols-outlined text-[24px]">system_update_alt</span>
<div>
<p className="font-semibold text-sm">Update Available: v{versionInfo.latest}</p>
<p className="text-xs opacity-80 mt-0.5">
{versionInfo.autoUpdateSupported
? t("updateAvailableDesc") ||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
: versionInfo.autoUpdateError ||
"Manual update required for this installation type."}
</p>
<div className="flex flex-col gap-3">
<div className="flex min-h-[64px] items-center justify-between rounded-lg border border-primary/20 bg-primary/10 px-5 py-4 text-primary">
<div className="flex min-w-0 items-center gap-4">
<span className="material-symbols-outlined shrink-0 text-[24px]">
system_update_alt
</span>
<div>
<p className="font-semibold text-sm">Update Available: v{versionInfo.latest}</p>
<p className="text-xs opacity-80 mt-0.5">
{versionInfo.autoUpdateSupported
? t("updateAvailableDesc") ||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
: versionInfo.autoUpdateError ||
"Manual update required for this installation type."}
</p>
</div>
</div>
<Button
size="sm"
onClick={versionInfo.autoUpdateSupported ? handleUpdate : undefined}
disabled={updating || !versionInfo.autoUpdateSupported}
className="ml-4 shrink-0 font-semibold"
title={versionInfo.autoUpdateError || ""}
>
{versionInfo.autoUpdateSupported ? t("updateNow") || "Update Now" : "Manual Update"}
</Button>
</div>
<Button
size="sm"
onClick={versionInfo.autoUpdateSupported ? handleUpdate : undefined}
disabled={updating || !versionInfo.autoUpdateSupported}
className="shrink-0 ml-4 font-semibold"
title={versionInfo.autoUpdateError || ""}
>
{versionInfo.autoUpdateSupported ? t("updateNow") || "Update Now" : "Manual Update"}
</Button>
{/* News Notification Banner */}
{versionInfo?.news && (
<div className="flex min-h-[64px] items-center justify-between rounded-lg border border-border bg-surface px-5 py-4">
<div className="flex min-w-0 items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-bg text-text-muted">
<span className="material-symbols-outlined text-[22px] text-primary">
{versionInfo.news.icon || "campaign"}
</span>
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-text-main">{versionInfo.news.title}</p>
<p className="mt-0.5 max-w-[560px] text-xs leading-relaxed text-text-muted">
{versionInfo.news.message}
</p>
</div>
</div>
{versionInfo.news.link && (
<a
href={versionInfo.news.link}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
>
{versionInfo.news.linkLabel || "Ler Mais"}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</a>
)}
</div>
)}
</div>
)}

View File

@@ -6,7 +6,12 @@ import { Card, Button, Input } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { useTranslations } from "next-intl";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { CLI_COMPAT_PROVIDER_IDS } from "@/shared/constants/cliCompatProviders";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import {
CLI_COMPAT_PROVIDER_IDS,
CLI_COMPAT_TOGGLE_IDS,
normalizeCliCompatProviderId,
} from "@/shared/constants/cliCompatProviders";
interface AgentInfo {
id: string;
@@ -106,6 +111,14 @@ export default function AgentsPage() {
}
};
const normalizedCliCompatProviders = Array.from(
new Set(
(settings.cliCompatProviders || [])
.map((providerId: string) => normalizeCliCompatProviderId(providerId))
.filter((providerId: string) => CLI_COMPAT_PROVIDER_IDS.includes(providerId))
)
);
const handleRefresh = async () => {
setRefreshing(true);
try {
@@ -307,19 +320,19 @@ export default function AgentsPage() {
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{ts("cliFingerprintDesc")}</p>
<div className="flex flex-wrap gap-2">
{CLI_COMPAT_PROVIDER_IDS.map((providerId) => {
const providerMeta = Object.values(AI_PROVIDERS).find(
(p: any) => p.id === providerId
) as any;
const isEnabled = (settings.cliCompatProviders || []).includes(providerId);
const displayName = providerMeta?.name || providerId;
{CLI_COMPAT_TOGGLE_IDS.map((toggleId) => {
const providerId = normalizeCliCompatProviderId(toggleId);
const providerMeta = Object.values(AI_PROVIDERS).find((p: any) => p.id === providerId) as any;
const toolMeta = CLI_TOOLS[toggleId as keyof typeof CLI_TOOLS] as any;
const isEnabled = normalizedCliCompatProviders.includes(providerId);
const displayName = toolMeta?.name || providerMeta?.name || toggleId;
const icon = providerMeta?.icon || "terminal";
const color = providerMeta?.color || "#888";
return (
<button
key={providerId}
key={toggleId}
onClick={() => {
const current: string[] = settings.cliCompatProviders || [];
const current = normalizedCliCompatProviders;
const updated = current.includes(providerId)
? current.filter((p) => p !== providerId)
: [...current, providerId];
@@ -347,11 +360,11 @@ export default function AgentsPage() {
);
})}
</div>
{(settings.cliCompatProviders || []).length > 0 && (
{normalizedCliCompatProviders.length > 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">verified</span>
{ts("cliFingerprintEnabled", {
count: (settings.cliCompatProviders || []).length,
count: normalizedCliCompatProviders.length,
})}
</p>
)}

View File

@@ -0,0 +1,146 @@
"use client";
import { useEffect, useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { Button } from "@/shared/components";
import {
CHANGELOG_GITHUB_URL,
CHANGELOG_RAW_URL,
getLatestChangelogMarkdown,
} from "@/shared/utils/releaseNotes";
function resolveChangelogHref(href: string | undefined): string | null {
if (!href) return null;
if (href.startsWith("#")) return href;
try {
const url = new URL(href, "https://github.com/diegosouzapw/OmniRoute/blob/main/");
return url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}
const markdownComponents: Components = {
h1({ children }) {
return <h1 className="mb-6 text-2xl font-bold text-text-main">{children}</h1>;
},
h2({ children }) {
return (
<h2 className="mt-8 mb-4 flex items-center gap-2 text-lg font-bold text-text-main first:mt-0">
<span className="material-symbols-outlined text-[20px] text-primary">sell</span>
{children}
</h2>
);
},
h3({ children }) {
return (
<h3 className="mt-5 mb-2 text-sm font-semibold uppercase text-text-main/80">{children}</h3>
);
},
p({ children }) {
return <p className="mb-2 text-sm leading-relaxed text-text-muted">{children}</p>;
},
ul({ children }) {
return <ul className="my-3 flex flex-col gap-2">{children}</ul>;
},
li({ children }) {
return (
<li className="ml-2 flex items-start text-sm leading-relaxed text-text-muted">
<span className="mr-3 mt-2 size-1.5 shrink-0 rounded-full bg-text-muted/30" />
<span>{children}</span>
</li>
);
},
strong({ children }) {
return <strong className="font-semibold text-text-main">{children}</strong>;
},
code({ children }) {
return (
<code className="rounded border border-black/5 bg-bg-subtle px-1.5 py-0.5 font-mono text-[13px] text-text-main dark:border-white/5">
{children}
</code>
);
},
a({ href, children }) {
const resolvedHref = resolveChangelogHref(href);
if (!resolvedHref) return <span>{children}</span>;
return (
<a
href={resolvedHref}
target={resolvedHref.startsWith("#") ? undefined : "_blank"}
rel={resolvedHref.startsWith("#") ? undefined : "noopener noreferrer"}
className="text-primary hover:underline"
>
{children}
</a>
);
},
};
export default function ChangelogViewer() {
const [markdown, setMarkdown] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
async function fetchChangelog() {
try {
const res = await fetch(CHANGELOG_RAW_URL, { cache: "no-store" });
if (!res.ok) throw new Error(`Changelog fetch failed with ${res.status}`);
const text = await res.text();
setMarkdown(getLatestChangelogMarkdown(text, 10));
} catch (err) {
console.error(err);
setError(true);
} finally {
setLoading(false);
}
}
fetchChangelog();
}, []);
if (loading) {
return (
<div className="flex flex-col items-center justify-center space-y-4 py-32">
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted/50">
sync
</span>
<p className="text-sm text-text-muted">Loading changelog from GitHub...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
<span className="material-symbols-outlined mb-4 text-[48px] text-red-500/50">
error_outline
</span>
<p>Could not load the changelog. Please try again later.</p>
<Button variant="secondary" className="mt-4" onClick={() => globalThis.location.reload()}>
Retry
</Button>
</div>
);
}
return (
<div className="p-8">
<div className="max-w-none">
<ReactMarkdown components={markdownComponents}>{markdown}</ReactMarkdown>
</div>
<div className="mt-12 flex justify-center border-t border-border pt-6">
<a href={CHANGELOG_GITHUB_URL} target="_blank" rel="noopener noreferrer">
<Button variant="secondary" className="gap-2 text-xs">
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
View Full History on GitHub
</Button>
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/shared/components";
import {
NEWS_JSON_URL,
parseActiveNewsPayload,
type NewsAnnouncement,
} from "@/shared/utils/releaseNotes";
export default function NewsViewer() {
const [news, setNews] = useState<NewsAnnouncement | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
async function fetchNews() {
try {
const res = await fetch(NEWS_JSON_URL, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
setNews(parseActiveNewsPayload(data));
} else {
setError(true);
}
} catch (err) {
console.error("Failed to fetch news:", err);
setError(true);
} finally {
setLoading(false);
}
}
fetchNews();
}, []);
if (loading) {
return (
<div className="flex justify-center py-20">
<span className="material-symbols-outlined animate-spin text-[32px] text-text-muted">
sync
</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
<span className="material-symbols-outlined text-[48px] text-red-500/50 mb-4">
error_outline
</span>
<p>Could not load announcements. Please try again later.</p>
</div>
);
}
if (!news || !news.active) {
return (
<div className="flex flex-col items-center justify-center py-20 text-text-muted">
<span className="material-symbols-outlined text-[48px] opacity-50 mb-4">
notifications_off
</span>
<p>No new announcements at this time.</p>
</div>
);
}
return (
<div className="p-8">
<div className="flex flex-col gap-6 border-l-4 border-primary pl-5 md:flex-row md:items-center md:pl-6">
<div className="size-14 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-[30px] text-primary">
{news.icon || "campaign"}
</span>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-main mb-2">{news.title}</h2>
<p className="text-sm text-text-muted leading-relaxed max-w-2xl">{news.message}</p>
</div>
{news.link && (
<div className="shrink-0 md:ml-auto">
<a href={news.link} target="_blank" rel="noopener noreferrer">
<Button variant="primary" className="gap-2">
{news.linkLabel || "Learn More"}
<span className="material-symbols-outlined text-[18px]">arrow_forward</span>
</Button>
</a>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, SegmentedControl } from "@/shared/components";
import ChangelogViewer from "./components/ChangelogViewer";
import NewsViewer from "./components/NewsViewer";
export default function ChangelogPage() {
const [activeTab, setActiveTab] = useState<"news" | "changelog">("news");
const t = useTranslations("sidebar");
const title = typeof t.has === "function" && t.has("changelog") ? t("changelog") : "Changelog";
return (
<div className="flex flex-col gap-6 max-w-5xl mx-auto w-full">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-text-main">{title}</h1>
<p className="text-sm text-text-muted mt-1">
Stay up to date with the latest platform features and announcements.
</p>
</div>
<div className="shrink-0 w-full sm:w-[240px]">
<SegmentedControl
options={[
{ label: "News", value: "news" },
{ label: "Changelog", value: "changelog" },
]}
value={activeTab}
onChange={(val) => setActiveTab(val as "news" | "changelog")}
/>
</div>
</div>
<Card className="min-h-[500px] overflow-hidden" padding="none">
{activeTab === "news" ? <NewsViewer /> : <ChangelogViewer />}
</Card>
</div>
);
}

View File

@@ -5,6 +5,10 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import {
getStoredClaudeAuthValue,
normalizeClaudeBaseUrl,
} from "@/shared/services/claudeCliConfig";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -98,7 +102,7 @@ export default function ClaudeToolCard({
}
});
// Restore selected key from file: match token stored in file against known keys
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
const tokenFromFile = getStoredClaudeAuthValue(env);
if (tokenFromFile) {
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Mask the token from file to compare against the masked list.
@@ -124,12 +128,12 @@ export default function ClaudeToolCard({
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
return normalizeClaudeBaseUrl(url);
};
const getDisplayUrl = () => {
const url = customBaseUrl || baseUrl;
return url.endsWith("/v1") ? url : `${url}/v1`;
return normalizeClaudeBaseUrl(url);
};
const handleApplySettings = async () => {
@@ -139,13 +143,9 @@ export default function ClaudeToolCard({
const env: any = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
// Fall back to sk_omniroute for localhost-only setups without a key.
// If no key is selected, leave auth unset so local installs can rely on
// anonymous access instead of persisting a fake placeholder token.
const selectedKeyId = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
const skOmnirouteFallback = !cloudEnabled ? "sk_omniroute" : null;
if (!selectedKeyId && skOmnirouteFallback) {
env.ANTHROPIC_AUTH_TOKEN = skOmnirouteFallback;
}
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias] || model.defaultValue || "";
@@ -221,13 +221,13 @@ export default function ClaudeToolCard({
// Generate settings.json content for manual copy
const getManualConfigs = () => {
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "<API_KEY_FROM_DASHBOARD>";
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl(), ANTHROPIC_AUTH_TOKEN: keyToUse };
const env = { ANTHROPIC_BASE_URL: getEffectiveBaseUrl() };
if (selectedApiKey && selectedApiKey.trim()) {
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
} else if (cloudEnabled) {
env.ANTHROPIC_AUTH_TOKEN = "<API_KEY_FROM_DASHBOARD>";
}
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
@@ -443,7 +443,7 @@ export default function ClaudeToolCard({
</select>
) : (
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
{cloudEnabled ? t("noApiKeysCreateOne") : t("defaultOmnirouteKey")}
{cloudEnabled ? t("noApiKeysCreateOne") : t("noApiKeysAvailable")}
</span>
)}
</div>

View File

@@ -103,6 +103,8 @@ export default function APIPageClient({ machineId }) {
const [tailscaleInstallBusy, setTailscaleInstallBusy] = useState(false);
const [tailscaleInstallLog, setTailscaleInstallLog] = useState<string[]>([]);
const [tailscalePassword, setTailscalePassword] = useState("");
const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true);
const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true);
const { copied, copy } = useCopyToClipboard();
@@ -308,6 +310,8 @@ export default function APIPageClient({ machineId }) {
if (data.machineId) {
setResolvedMachineId(data.machineId);
}
setShowCloudflaredTunnel(data.hideEndpointCloudflaredTunnel !== true);
setShowTailscaleFunnel(data.hideEndpointTailscaleFunnel !== true);
}
} catch (error) {
console.log("Error loading cloud settings:", error);
@@ -989,236 +993,242 @@ export default function APIPageClient({ machineId }) {
</Button>
</div>
<div className="rounded-xl border border-border/70 bg-surface/40 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${cloudflaredPhaseMeta[cloudflaredPhase].className}`}
>
{cloudflaredPhaseMeta[cloudflaredPhase].label}
</span>
{showCloudflaredTunnel && (
<div className="rounded-xl border border-border/70 bg-surface/40 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${cloudflaredPhaseMeta[cloudflaredPhase].className}`}
>
{cloudflaredPhaseMeta[cloudflaredPhase].label}
</span>
</div>
</div>
</div>
{cloudflaredStatus?.supported !== false && (
<Button
size="sm"
variant={cloudflaredStatus?.running ? "secondary" : "primary"}
icon={cloudflaredStatus?.running ? "cloud_off" : "cloud_upload"}
onClick={() =>
handleCloudflaredAction(cloudflaredStatus?.running ? "disable" : "enable")
}
loading={cloudflaredBusy}
className={
cloudflaredStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-primary to-cyan-500 hover:from-primary-hover hover:to-cyan-600"
}
>
{cloudflaredActionLabel}
</Button>
)}
</div>
{cloudflaredNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
cloudflaredNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: cloudflaredNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{cloudflaredNotice.type === "success"
? "check_circle"
: cloudflaredNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{cloudflaredNotice.message}</span>
<button
onClick={() => setCloudflaredNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<p className="text-xs text-text-muted">{cloudflaredUrlNotice}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={cloudflaredStatus?.apiUrl || ""}
readOnly
placeholder="https://*.trycloudflare.com/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "cloudflared_url" ? "check" : "content_copy"}
onClick={() =>
cloudflaredStatus?.apiUrl && copy(cloudflaredStatus.apiUrl, "cloudflared_url")
}
disabled={!cloudflaredStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "cloudflared_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{cloudflaredStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("cloudflaredLastError", "Last error: {error}", {
error: cloudflaredStatus.lastError,
})}
</p>
)}
</div>
</div>
<div className="mt-4 rounded-xl border border-border/70 bg-surface/40 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("tailscaleTitle", "Tailscale Funnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${tailscalePhaseMeta[tailscalePhase].className}`}
>
{tailscalePhaseMeta[tailscalePhase].label}
</span>
</div>
</div>
{tailscaleStatus?.supported !== false && (
<Button
size="sm"
variant={tailscaleStatus?.running ? "secondary" : "primary"}
icon={tailscaleStatus?.running ? "vpn_lock_off" : "vpn_lock"}
onClick={() => {
if (tailscaleStatus?.running) {
void handleTailscaleDisable();
} else if (!tailscaleStatus?.installed) {
setShowTailscaleInstallModal(true);
} else {
void handleTailscaleEnable();
{cloudflaredStatus?.supported !== false && (
<Button
size="sm"
variant={cloudflaredStatus?.running ? "secondary" : "primary"}
icon={cloudflaredStatus?.running ? "cloud_off" : "cloud_upload"}
onClick={() =>
handleCloudflaredAction(cloudflaredStatus?.running ? "disable" : "enable")
}
}}
loading={tailscaleBusy}
className={
tailscaleStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-indigo-500 to-cyan-500 hover:from-indigo-600 hover:to-cyan-600"
}
>
{tailscaleActionLabel}
</Button>
)}
</div>
{tailscaleNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
tailscaleNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: tailscaleNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{tailscaleNotice.type === "success"
? "check_circle"
: tailscaleNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{tailscaleNotice.message}</span>
<button
onClick={() => setTailscaleNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<p className="text-xs text-text-muted">{tailscaleUrlNotice}</p>
{tailscaleStatus?.phase === "needs_login" && (
<p className="text-xs text-blue-400">
{translateOrFallback(
"tailscaleNeedsLoginHint",
"Authenticate this machine with Tailscale, then enable Funnel."
loading={cloudflaredBusy}
className={
cloudflaredStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-primary to-cyan-500 hover:from-primary-hover hover:to-cyan-600"
}
>
{cloudflaredActionLabel}
</Button>
)}
</p>
)}
{/* Sudo password input — shown when Tailscale is installed but not running (needs sudo to start daemon) */}
{tailscaleStatus?.installed &&
!tailscaleStatus?.running &&
tailscaleStatus?.platform !== "win32" && (
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted">
{translateOrFallback(
"tailscaleSudoLabel",
"Sudo Password (required on macOS/Linux)"
)}
</label>
<Input
type="password"
value={tailscalePassword}
onChange={(event) => setTailscalePassword(event.target.value)}
placeholder={translateOrFallback(
"tailscaleSudoPlaceholder",
"Enter sudo password"
)}
disabled={tailscaleBusy}
className="font-mono text-sm"
/>
</div>
{cloudflaredNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
cloudflaredNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: cloudflaredNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{cloudflaredNotice.type === "success"
? "check_circle"
: cloudflaredNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{cloudflaredNotice.message}</span>
<button
onClick={() => setCloudflaredNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={tailscaleStatus?.apiUrl || ""}
readOnly
placeholder="https://your-device.tailnet.ts.net/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "tailscale_url" ? "check" : "content_copy"}
onClick={() =>
tailscaleStatus?.apiUrl && copy(tailscaleStatus.apiUrl, "tailscale_url")
}
disabled={!tailscaleStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "tailscale_url" ? tc("copied") : tc("copy")}
</Button>
<p className="text-xs text-text-muted">{cloudflaredUrlNotice}</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={cloudflaredStatus?.apiUrl || ""}
readOnly
placeholder="https://*.trycloudflare.com/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "cloudflared_url" ? "check" : "content_copy"}
onClick={() =>
cloudflaredStatus?.apiUrl && copy(cloudflaredStatus.apiUrl, "cloudflared_url")
}
disabled={!cloudflaredStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "cloudflared_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{cloudflaredStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("cloudflaredLastError", "Last error: {error}", {
error: cloudflaredStatus.lastError,
})}
</p>
)}
</div>
{tailscaleStatus?.binaryPath && (
<p className="text-xs text-text-muted">
{translateOrFallback("tailscaleBinaryPath", "Binary: {path}", {
path: tailscaleStatus.binaryPath,
})}
</p>
)}
{tailscaleStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("tailscaleLastError", "Last error: {error}", {
error: tailscaleStatus.lastError,
})}
</p>
)}
</div>
</div>
)}
{showTailscaleFunnel && (
<div
className={`${showCloudflaredTunnel ? "mt-4 " : ""}rounded-xl border border-border/70 bg-surface/40 p-4`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("tailscaleTitle", "Tailscale Funnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${tailscalePhaseMeta[tailscalePhase].className}`}
>
{tailscalePhaseMeta[tailscalePhase].label}
</span>
</div>
</div>
{tailscaleStatus?.supported !== false && (
<Button
size="sm"
variant={tailscaleStatus?.running ? "secondary" : "primary"}
icon={tailscaleStatus?.running ? "vpn_lock_off" : "vpn_lock"}
onClick={() => {
if (tailscaleStatus?.running) {
void handleTailscaleDisable();
} else if (!tailscaleStatus?.installed) {
setShowTailscaleInstallModal(true);
} else {
void handleTailscaleEnable();
}
}}
loading={tailscaleBusy}
className={
tailscaleStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-indigo-500 to-cyan-500 hover:from-indigo-600 hover:to-cyan-600"
}
>
{tailscaleActionLabel}
</Button>
)}
</div>
{tailscaleNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
tailscaleNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: tailscaleNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{tailscaleNotice.type === "success"
? "check_circle"
: tailscaleNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{tailscaleNotice.message}</span>
<button
onClick={() => setTailscaleNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<p className="text-xs text-text-muted">{tailscaleUrlNotice}</p>
{tailscaleStatus?.phase === "needs_login" && (
<p className="text-xs text-blue-400">
{translateOrFallback(
"tailscaleNeedsLoginHint",
"Authenticate this machine with Tailscale, then enable Funnel."
)}
</p>
)}
{/* Sudo password input — shown when Tailscale is installed but not running (needs sudo to start daemon) */}
{tailscaleStatus?.installed &&
!tailscaleStatus?.running &&
tailscaleStatus?.platform !== "win32" && (
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted">
{translateOrFallback(
"tailscaleSudoLabel",
"Sudo Password (required on macOS/Linux)"
)}
</label>
<Input
type="password"
value={tailscalePassword}
onChange={(event) => setTailscalePassword(event.target.value)}
placeholder={translateOrFallback(
"tailscaleSudoPlaceholder",
"Enter sudo password"
)}
disabled={tailscaleBusy}
className="font-mono text-sm"
/>
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={tailscaleStatus?.apiUrl || ""}
readOnly
placeholder="https://your-device.tailnet.ts.net/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "tailscale_url" ? "check" : "content_copy"}
onClick={() =>
tailscaleStatus?.apiUrl && copy(tailscaleStatus.apiUrl, "tailscale_url")
}
disabled={!tailscaleStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "tailscale_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{tailscaleStatus?.binaryPath && (
<p className="text-xs text-text-muted">
{translateOrFallback("tailscaleBinaryPath", "Binary: {path}", {
path: tailscaleStatus.binaryPath,
})}
</p>
)}
{tailscaleStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("tailscaleLastError", "Last error: {error}", {
error: tailscaleStatus.lastError,
})}
</p>
)}
</div>
</div>
)}
</Card>
<Card>

View File

@@ -2730,7 +2730,7 @@ export default function ProviderDetailPage() {
{isCompatible && providerNode && (
<Card>
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold">
{isCcCompatible
@@ -2743,7 +2743,7 @@ export default function ProviderDetailPage() {
{getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" icon="add" onClick={() => setShowAddApiKeyModal(true)}>
{t("add")}
</Button>
@@ -2788,6 +2788,16 @@ export default function ProviderDetailPage() {
</Button>
</div>
</div>
{isCcCompatible && (
<div className="mb-4 rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
</Card>
)}
@@ -3525,12 +3535,12 @@ function ModelRow({
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel", "Testing...")
? t("testingModel")
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
: t("testModel", "Test Model")
: t("testModel")
}
>
{testingModel ? (
@@ -3911,12 +3921,12 @@ function PassthroughModelRow({
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel", "Testing...")
? t("testingModel")
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
: t("testModel", "Test Model")
: t("testModel")
}
>
{testingModel ? (
@@ -5772,6 +5782,7 @@ function AddApiKeyModal({
}
let isValid = false;
let validationError: string | null = null;
try {
setValidating(true);
setValidationResult(null);
@@ -5789,6 +5800,9 @@ function AddApiKeyModal({
});
const data = await res.json();
isValid = !!data.valid;
if (!isValid && data.error) {
validationError = data.error;
}
setValidationResult(isValid ? "success" : "failed");
} catch {
setValidationResult("failed");
@@ -5797,8 +5811,13 @@ function AddApiKeyModal({
}
if (!isValid) {
setSaveError(t("apiKeyValidationFailed"));
return;
if (apiKeyOptional && !formData.apiKey) {
// Bypass validation block for local/optional providers when no key is provided
console.debug("Validation failed but apiKey is optional; proceeding to save.");
} else {
setSaveError(validationError || t("apiKeyValidationFailed"));
return;
}
}
const providerSpecificData: Record<string, unknown> = {};
@@ -5860,6 +5879,16 @@ function AddApiKeyModal({
onClose={onClose}
>
<div className="flex flex-col gap-4">
{isCcCompatible && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
<Input
label={t("nameLabel")}
value={formData.name}
@@ -5920,17 +5949,15 @@ function AddApiKeyModal({
/>
</div>
)}
{isCompatible && (
{isCompatible && !isCcCompatible && (
<p className="text-xs text-text-muted">
{isCcCompatible
? t("ccCompatibleValidationHint")
: isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
})
: t("validationChecksOpenAiCompatible", {
provider: providerName || t("openaiCompatibleName"),
})}
{isAnthropic
? t("validationChecksAnthropicCompatible", {
provider: providerName || t("anthropicCompatibleName"),
})
: t("validationChecksOpenAiCompatible", {
provider: providerName || t("openaiCompatibleName"),
})}
</p>
)}
<button
@@ -6959,6 +6986,16 @@ function EditCompatibleNodeModal({
onClose={onClose}
>
<div className="flex flex-col gap-4">
{isCcCompatible && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
<Input
label={t("nameLabel")}
value={formData.name}

View File

@@ -1044,7 +1044,6 @@ export default function ProvidersPage() {
<AddCcCompatibleModal
isOpen={showAddCcCompatibleModal}
addLabel={addCcCompatibleLabel}
compatibleLabel={ccCompatibleLabel}
onClose={() => setShowAddCcCompatibleModal(false)}
onCreated={(node) => {
setProviderNodes((prev) => [...prev, node]);
@@ -1752,12 +1751,12 @@ AddAnthropicCompatibleModal.propTypes = {
onCreated: PropTypes.func.isRequired,
};
function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCreated }) {
function AddCcCompatibleModal({ isOpen, addLabel, onClose, onCreated }) {
const t = useTranslations("providers");
const [formData, setFormData] = useState({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com",
baseUrl: "",
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
});
const [submitting, setSubmitting] = useState(false);
@@ -1765,6 +1764,10 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const hasRequiredFields = Boolean(
formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim()
);
const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim());
useEffect(() => {
if (isOpen) {
@@ -1774,7 +1777,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
}, [isOpen]);
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
if (!hasRequiredFields) return;
setSubmitting(true);
try {
const res = await fetch("/api/provider-nodes", {
@@ -1795,7 +1798,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
setFormData({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com",
baseUrl: "",
chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH,
});
setCheckKey("");
@@ -1835,26 +1838,34 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
return (
<Modal isOpen={isOpen} title={addLabel} onClose={onClose}>
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
<Input
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t("compatibleProdPlaceholder", { type: compatibleLabel })}
hint={t("nameHint")}
placeholder={t("ccCompatibleNamePlaceholder")}
hint={t("ccCompatibleNameHint")}
/>
<Input
label={t("prefixLabel")}
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="cc-prod"
hint={t("prefixHint")}
placeholder={t("ccCompatiblePrefixPlaceholder")}
hint={t("ccCompatiblePrefixHint")}
/>
<Input
label={t("baseUrlLabel")}
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder="https://api.anthropic.com"
hint={t("compatibleBaseUrlHint", { type: compatibleLabel })}
placeholder={t("ccCompatibleBaseUrlPlaceholder")}
hint={t("ccCompatibleBaseUrlHint")}
/>
<button
type="button"
@@ -1881,7 +1892,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
value={formData.chatPath}
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH}
hint={t("chatPathHint")}
hint={t("ccCompatibleChatPathHint")}
/>
</div>
)}
@@ -1896,7 +1907,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
<div className="pt-6">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
disabled={!canValidate || validating}
variant="secondary"
>
{validating ? t("checking") : t("check")}
@@ -1909,16 +1920,7 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
</Badge>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim() ||
submitting
}
>
<Button onClick={handleSubmit} fullWidth disabled={!hasRequiredFields || submitting}>
{submitting ? t("creating") : t("add")}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
@@ -1933,7 +1935,6 @@ function AddCcCompatibleModal({ isOpen, addLabel, compatibleLabel, onClose, onCr
AddCcCompatibleModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
addLabel: PropTypes.string.isRequired,
compatibleLabel: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};

View File

@@ -36,6 +36,8 @@ export default function AppearanceTab() {
);
const hiddenSidebarSet = new Set(hiddenSidebarItems);
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
const getSettingsLabel = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
@@ -255,6 +257,60 @@ export default function AppearanceTab() {
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">
{getSettingsLabel("endpointTunnelVisibility", "Endpoint tunnel visibility")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"endpointTunnelVisibilityDesc",
"Hide tunnel controls from the Endpoint page without changing tunnel state."
)}
</p>
</div>
<div className="rounded-lg border border-border bg-surface/40 divide-y divide-border/70">
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("showCloudflareTunnel", "Cloudflare Quick Tunnel")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"showCloudflareTunnelDesc",
"Show Cloudflare Quick Tunnel controls on the Endpoint page."
)}
</p>
</div>
<Toggle
checked={showCloudflaredTunnel}
onChange={(checked) => updateSetting("hideEndpointCloudflaredTunnel", !checked)}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">
{getSettingsLabel("showTailscaleFunnel", "Tailscale Funnel")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"showTailscaleFunnelDesc",
"Show Tailscale Funnel controls on the Endpoint page."
)}
</p>
</div>
<Toggle
checked={showTailscaleFunnel}
onChange={(checked) => updateSetting("hideEndpointTailscaleFunnel", !checked)}
disabled={loading}
/>
</div>
</div>
</div>
<div className="pt-4 border-t border-border">
<div className="mb-3">
<p className="font-medium">

View File

@@ -38,6 +38,23 @@ type TestResult = {
error?: string;
};
type ParsedProxyEntry = {
name: string;
host: string;
port: number;
username: string;
password: string;
type: string;
region: string;
status: string;
notes: string;
};
type ParseError = {
line: number;
reason: string;
};
const EMPTY_FORM = {
id: "",
name: "",
@@ -51,6 +68,85 @@ const EMPTY_FORM = {
status: "active",
};
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# SOCKS5 examples:
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
#
# HTTP/HTTPS examples:
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
# https-proxy|proxy.example.com|443|admin|secret123|https|US|active
`;
const VALID_TYPES = new Set(["http", "https", "socks5"]);
const VALID_STATUSES = new Set(["active", "inactive"]);
function parseBulkImportText(text: string): {
entries: ParsedProxyEntry[];
errors: ParseError[];
skipped: number;
} {
const lines = text.split("\n");
const entries: ParsedProxyEntry[] = [];
const errors: ParseError[] = [];
let skipped = 0;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i].trim();
if (!raw || raw.startsWith("#")) {
skipped++;
continue;
}
const parts = raw.split("|").map((p) => p.trim());
const [name, host, portStr, username, password, type, region, status, notes] = parts;
const lineNum = i + 1;
if (!name) {
errors.push({ line: lineNum, reason: "Missing NAME" });
continue;
}
if (!host) {
errors.push({ line: lineNum, reason: "Missing HOST" });
continue;
}
const port = Number(portStr);
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
errors.push({ line: lineNum, reason: "Invalid PORT (must be 1-65535)" });
continue;
}
const normalizedType = (type || "socks5").toLowerCase();
if (!VALID_TYPES.has(normalizedType)) {
errors.push({ line: lineNum, reason: `Invalid TYPE '${type}' (use http, https, or socks5)` });
continue;
}
const normalizedStatus = (status || "active").toLowerCase();
if (!VALID_STATUSES.has(normalizedStatus)) {
errors.push({ line: lineNum, reason: `Invalid STATUS '${status}' (use active or inactive)` });
continue;
}
entries.push({
name,
host,
port,
username: username || "",
password: password || "",
type: normalizedType,
region: region || "",
status: normalizedStatus,
notes: notes || "",
});
}
return { entries, errors, skipped };
}
export default function ProxyRegistryManager() {
const t = useTranslations("proxyRegistry");
const [items, setItems] = useState<ProxyItem[]>([]);
@@ -72,6 +168,20 @@ export default function ProxyRegistryManager() {
const [bulkScopeIds, setBulkScopeIds] = useState("");
const [bulkProxyId, setBulkProxyId] = useState("");
// Bulk Import state
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
const [bulkImporting, setBulkImporting] = useState(false);
const [bulkImportResult, setBulkImportResult] = useState<{
created: number;
updated: number;
failed: number;
} | null>(null);
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
@@ -382,6 +492,77 @@ export default function ProxyRegistryManager() {
}
};
const handleBulkImportParse = () => {
const { entries, errors, skipped } = parseBulkImportText(bulkImportText);
setBulkImportParsed(entries);
setBulkImportErrors(errors);
setBulkImportSkipped(skipped);
setBulkImportParsedOnce(true);
setBulkImportResult(null);
};
const handleBulkImportExecute = async () => {
if (bulkImportParsed.length === 0) return;
if (bulkImportParsed.length > 100) {
setError(t("bulkImportMaxExceeded"));
return;
}
setBulkImporting(true);
setError(null);
setBulkImportResult(null);
try {
const payload = {
items: bulkImportParsed.map((entry) => ({
name: entry.name,
type: entry.type,
host: entry.host,
port: entry.port,
username: entry.username || undefined,
password: entry.password || undefined,
region: entry.region || null,
notes: entry.notes || null,
status: entry.status as "active" | "inactive",
})),
};
const res = await fetch("/api/settings/proxies/bulk-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to import proxies");
return;
}
setBulkImportResult({
created: data.created || 0,
updated: data.updated || 0,
failed: data.failed || 0,
});
await load();
} catch (e: any) {
setError(e?.message || "Failed to import proxies");
} finally {
setBulkImporting(false);
}
};
const openBulkImport = () => {
setBulkImportText(BULK_IMPORT_TEMPLATE);
setBulkImportParsed([]);
setBulkImportErrors([]);
setBulkImportSkipped(0);
setBulkImportParsedOnce(false);
setBulkImportResult(null);
setBulkImportOpen(true);
};
return (
<>
<Card className="p-6">
@@ -401,6 +582,15 @@ export default function ProxyRegistryManager() {
>
{t("importLegacy")}
</Button>
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={openBulkImport}
data-testid="proxy-registry-open-bulk-import"
>
{t("bulkImport")}
</Button>
<Button
size="sm"
variant="secondary"
@@ -726,6 +916,158 @@ export default function ProxyRegistryManager() {
</div>
</div>
</Modal>
{/* Bulk Import Modal */}
<Modal
isOpen={bulkImportOpen}
onClose={() => {
if (!bulkImporting) setBulkImportOpen(false);
}}
title={t("bulkImportTitle")}
maxWidth="xl"
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("bulkImportDescription")}</p>
<div>
<textarea
data-testid="proxy-registry-bulk-import-textarea"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
rows={14}
value={bulkImportText}
onChange={(e) => {
setBulkImportText(e.target.value);
setBulkImportParsedOnce(false);
setBulkImportResult(null);
}}
spellCheck={false}
/>
</div>
{/* Parse button */}
<div className="flex items-center gap-3">
<Button
size="sm"
variant="secondary"
icon="search"
onClick={handleBulkImportParse}
data-testid="proxy-registry-bulk-import-parse"
>
{t("bulkImportParse")}
</Button>
{bulkImportParsedOnce && (
<div className="flex items-center gap-3 text-xs">
<span className="text-emerald-400">
{t("bulkImportParsed", { count: bulkImportParsed.length })}
</span>
<span className="text-text-muted">
{t("bulkImportSkipped", { count: bulkImportSkipped })}
</span>
{bulkImportErrors.length > 0 && (
<span className="text-red-400">
{t("bulkImportParseErrors", { count: bulkImportErrors.length })}
</span>
)}
</div>
)}
</div>
{/* Parse errors */}
{bulkImportErrors.length > 0 && (
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
{bulkImportErrors.map((err, idx) => (
<div key={idx} className="text-xs text-red-400">
{t("bulkImportErrorLine", { line: err.line, reason: err.reason })}
</div>
))}
</div>
)}
{/* Preview table */}
{bulkImportParsedOnce && bulkImportParsed.length > 0 && (
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded border border-border">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
<th className="py-1.5 px-2">Name</th>
<th className="py-1.5 px-2">Type</th>
<th className="py-1.5 px-2">Host</th>
<th className="py-1.5 px-2">Port</th>
<th className="py-1.5 px-2">User</th>
<th className="py-1.5 px-2">Region</th>
<th className="py-1.5 px-2">Status</th>
</tr>
</thead>
<tbody>
{bulkImportParsed.map((entry, idx) => (
<tr key={idx} className="border-b border-border/40">
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
<td className="py-1 px-2">
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px]">
{entry.type}
</span>
</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.host}</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
<td className="py-1 px-2">
<span
className={
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
}
>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* No valid entries warning */}
{bulkImportParsedOnce &&
bulkImportParsed.length === 0 &&
bulkImportErrors.length === 0 && (
<div className="text-sm text-amber-400">{t("bulkImportNoValidEntries")}</div>
)}
{/* Import result */}
{bulkImportResult && (
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
{t("bulkImportSuccess", {
created: bulkImportResult.created,
updated: bulkImportResult.updated,
failed: bulkImportResult.failed,
})}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setBulkImportOpen(false)}>
{t("cancel")}
</Button>
<Button
size="sm"
icon="upload"
onClick={handleBulkImportExecute}
loading={bulkImporting}
disabled={!bulkImportParsedOnce || bulkImportParsed.length === 0}
data-testid="proxy-registry-bulk-import-execute"
>
{bulkImporting
? t("bulkImportImporting")
: bulkImportParsed.length > 0
? t("bulkImportImport", { count: bulkImportParsed.length })
: t("bulkImport")}
</Button>
</div>
</div>
</Modal>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { normalizeClaudeBaseUrl } from "@/shared/services/claudeCliConfig";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliSettingsEnvSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -122,7 +123,7 @@ export async function POST(request: Request) {
env.ANTHROPIC_AUTH_TOKEN = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever value was in env (e.g. sk_omniroute)
// Non-critical: fall back to whatever value was already provided in env.
}
}
@@ -146,11 +147,9 @@ export async function POST(request: Request) {
}
}
// Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix
// Claude Code gateway mode expects the unified root endpoint, not a forced /v1 suffix.
if (env.ANTHROPIC_BASE_URL) {
env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1")
? env.ANTHROPIC_BASE_URL
: `${env.ANTHROPIC_BASE_URL}/v1`;
env.ANTHROPIC_BASE_URL = normalizeClaudeBaseUrl(env.ANTHROPIC_BASE_URL);
}
// Merge new env with existing settings
@@ -186,6 +185,7 @@ export async function POST(request: Request) {
const RESET_ENV_KEYS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",

View File

@@ -0,0 +1,75 @@
import { upsertProxy } from "@/lib/localDb";
import { bulkImportProxiesSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(bulkImportProxiesSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { items } = validation.data;
let created = 0;
let updated = 0;
let failed = 0;
const results: Array<{
name: string;
success: boolean;
action?: "created" | "updated";
id?: string;
error?: string;
}> = [];
for (const item of items) {
try {
const result = await upsertProxy(item);
if (result.proxy) {
if (result.action === "created") created++;
else updated++;
results.push({
name: item.name,
success: true,
action: result.action,
id: result.proxy.id,
});
} else {
failed++;
results.push({ name: item.name, success: false, error: "Unknown error" });
}
} catch (error) {
failed++;
results.push({
name: item.name,
success: false,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
return Response.json({ created, updated, failed, results });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to bulk import proxies");
}
}

View File

@@ -15,6 +15,7 @@ import {
launchAutoUpdate,
validateAutoUpdateRuntime,
} from "@/lib/system/autoUpdate";
import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes";
const execFileAsync = promisify(execFile);
@@ -50,16 +51,32 @@ function isNewer(a: string | null, b: string): boolean {
return aPat > bPat;
}
async function getNews() {
try {
const res = await fetch(NEWS_JSON_URL, { next: { revalidate: 3600 } });
if (!res.ok) return null;
const data = await res.json();
return parseActiveNewsPayload(data);
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const current = getCurrentVersion();
const latest = await getLatestNpmVersion();
const updateAvailable = isNewer(latest, current);
const config = getAutoUpdateConfig();
const validation = await validateAutoUpdateRuntime(config);
const [latest, news, validation] = await Promise.all([
getLatestNpmVersion(),
getNews(),
validateAutoUpdateRuntime(config),
]);
const updateAvailable = isNewer(latest, current);
return NextResponse.json({
current,
@@ -68,6 +85,7 @@ export async function GET(req: NextRequest) {
channel: config.mode,
autoUpdateSupported: validation.supported,
autoUpdateError: validation.reason,
news,
});
}

View File

@@ -1,10 +1,5 @@
import { handleImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import { parseImageModel, getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -99,18 +94,11 @@ export async function POST(request: Request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image");
}
const apiKey = extractApiKey(request);
if (!isValidApiKey(apiKey)) {
const policyError = enforceApiKeyPolicy(apiKey);
if (policyError) {
return new Response(JSON.stringify(policyError.body), {
status: policyError.status,
headers: { "Content-Type": "application/json" },
});
}
}
const fullModel = model || "cgpt-web/gpt-5.3-instant";
const policy = await enforceApiKeyPolicy(request, fullModel);
if (policy.rejection) return policy.rejection;
const parsed = parseImageModel(fullModel);
const providerConfig = getImageProvider(parsed.provider);
if (!providerConfig) {
@@ -126,7 +114,16 @@ export async function POST(request: Request) {
);
}
const credentials = await getProviderCredentials(parsed.provider, apiKey);
const allowedConnections =
policy.apiKeyInfo?.allowedConnections && policy.apiKeyInfo.allowedConnections.length > 0
? policy.apiKeyInfo.allowedConnections
: null;
const credentials = await getProviderCredentials(
parsed.provider,
null,
allowedConnections,
fullModel
);
if (!credentials) {
return errorResponse(
HTTP_STATUS.UNAUTHORIZED,

View File

@@ -5,12 +5,19 @@ import { NextIntlClientProvider } from "next-intl";
import { getMessages, getLocale } from "next-intl/server";
import { RTL_LOCALES } from "@/i18n/config";
import { getSettings } from "@/lib/db/settings";
import type { Viewport } from "next";
import { PwaRegister } from "@/shared/components/PwaRegister";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const viewport: Viewport = {
themeColor: "#0b0f1a",
viewportFit: "cover",
};
export async function generateMetadata() {
const settings = await getSettings();
const instanceName = settings?.instanceName || "OmniRoute";
@@ -20,9 +27,25 @@ export async function generateMetadata() {
title: `${instanceName} — AI Gateway for Multi-Provider LLMs`,
description:
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
manifest: "/manifest.webmanifest",
applicationName: instanceName,
appleWebApp: {
capable: true,
title: instanceName,
statusBarStyle: "black-translucent",
},
other: {
"mobile-web-app-capable": "yes",
},
icons: {
icon: customFaviconUrl ? "/api/settings/favicon" : "/favicon.svg",
apple: "/apple-touch-icon.svg",
icon: customFaviconUrl
? "/api/settings/favicon"
: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/icon-512.png", type: "image/png", sizes: "512x512" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
};
}
@@ -67,6 +90,7 @@ export default async function RootLayout({ children }) {
Skip to content
</a>
<NextIntlClientProvider locale={locale} messages={messages}>
<PwaRegister />
<ThemeProvider>{children}</ThemeProvider>
</NextIntlClientProvider>
</body>

29
src/app/manifest.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "OmniRoute",
short_name: "OmniRoute",
description:
"OmniRoute is an AI gateway for multi-provider LLMs. One endpoint for all your AI providers.",
start_url: "/",
scope: "/",
display: "fullscreen",
orientation: "any",
background_color: "#0b0f1a",
theme_color: "#0b0f1a",
icons: [
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
{
src: "/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
],
};
}

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "المواضيع",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Теми",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Motivy",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Temaer",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themen",

View File

@@ -270,7 +270,7 @@
"editCombo": "Edit Combo",
"testResults": "Test Results",
"searchQuery": "Search Query",
"addCcCompatible": "Add Cc Compatible",
"addCcCompatible": "Add CC Compatible",
"duplicate": "Duplicate",
"createCombo": "Create Combo",
"searchTypeWeb": "Search Type Web",
@@ -344,7 +344,7 @@
"signatureDefaults": "Signature Defaults",
"errorCreating": "Error Creating",
"timeRangeYear": "Time Range Year",
"compatibleLabel": "Compatible Label",
"compatibleLabel": "Compatible",
"cloudDisabledSuccess": "Cloud Disabled Success",
"deleteConfirm": "Delete Confirm",
"check": "Check",
@@ -511,7 +511,7 @@
"comboUpdated": "Combo Updated",
"weighted": "Weighted",
"providers": "Providers",
"ccCompatibleLabel": "Cc Compatible Label",
"ccCompatibleLabel": "CC Compatible",
"noFallbackChainsDesc": "No Fallback Chains Desc",
"yesImport": "Yes Import",
"lockoutsAutoRefreshHint": "Lockouts Auto Refresh Hint",
@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",
@@ -2704,7 +2705,7 @@
"accountIdLabel": "Account Id Label",
"accountIdPlaceholder": "Account Id Placeholder",
"addAnotherApiKey": "Add Another Api Key",
"addCcCompatible": "Add Cc Compatible",
"addCcCompatible": "Add CC Compatible",
"aggregatorsGateways": "Aggregators Gateways",
"apiFormatLabel": "Api Format Label",
"apiKeyOptionalHint": "Api Key Optional Hint",
@@ -2721,27 +2722,27 @@
"bailianBaseUrlHint": "Bailian Base Url Hint",
"blackboxWebCookieHint": "Blackbox Web Cookie Hint",
"blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder",
"blockClaudeExtraUsageDescription": "Block Claude Extra Usage Description",
"blockClaudeExtraUsageLabel": "Block Claude Extra Usage Label",
"ccCompatibleBaseUrlHint": "Cc Compatible Base Url Hint",
"ccCompatibleBaseUrlPlaceholder": "Cc Compatible Base Url Placeholder",
"ccCompatibleChatPathHint": "Cc Compatible Chat Path Hint",
"ccCompatibleContext1mDescription": "Cc Compatible Context1M Description",
"ccCompatibleContext1mLabel": "Cc Compatible Context1M Label",
"ccCompatibleDetailsTitle": "Cc Compatible Details Title",
"ccCompatibleLabel": "Cc Compatible Label",
"ccCompatibleModelsDescription": "Cc Compatible Models Description",
"ccCompatibleNameHint": "Cc Compatible Name Hint",
"ccCompatibleNamePlaceholder": "Cc Compatible Name Placeholder",
"ccCompatiblePrefixHint": "Cc Compatible Prefix Hint",
"ccCompatiblePrefixPlaceholder": "Cc Compatible Prefix Placeholder",
"ccCompatibleValidationHint": "Cc Compatible Validation Hint",
"claudeExtraUsageShort": "Claude Extra Usage Short",
"claudeExtraUsageToggleTitle": "Claude Extra Usage Toggle Title",
"codex5hToggleTitle": "Codex5H Toggle Title",
"blockClaudeExtraUsageDescription": "Hide extra Claude usage rows reported by some providers when they duplicate primary token accounting.",
"blockClaudeExtraUsageLabel": "Block duplicate Claude usage rows",
"ccCompatibleBaseUrlHint": "Base URL for a Claude Code-only relay. Do not include /messages.",
"ccCompatibleBaseUrlPlaceholder": "https://relay.example.com/v1",
"ccCompatibleChatPathHint": "Defaults to Claude Code's strict Messages API path. Change only if your relay documents a different path.",
"ccCompatibleContext1mDescription": "Adds the context-1m beta header when the selected Claude model supports it.",
"ccCompatibleContext1mLabel": "Enable 1M context beta",
"ccCompatibleDetailsTitle": "CC-compatible relay details",
"ccCompatibleLabel": "CC Compatible",
"ccCompatibleModelsDescription": "CC-compatible relays do not expose model listing. Add the Claude model IDs your relay accepts.",
"ccCompatibleNameHint": "Display name for this Claude Code-only relay.",
"ccCompatibleNamePlaceholder": "CC Relay Production",
"ccCompatiblePrefixHint": "Used in model aliases such as prefix/model-id.",
"ccCompatiblePrefixPlaceholder": "cc",
"ccCompatibleValidationHint": "Use this provider only for relays that serve Claude Code clients exclusively. OmniRoute rewrites any incoming request into the Claude Code-compatible wire format so those relays accept it. If you only want to use Claude Code CLI, or you are not sure what this relay type means, use a regular Anthropic-compatible provider instead.",
"claudeExtraUsageShort": "Extra usage",
"claudeExtraUsageToggleTitle": "Block Claude extra usage accounting for this connection",
"codex5hToggleTitle": "Track Codex 5-hour quota for this connection",
"codexFastServiceTierDescription": "Use the priority service tier for Codex requests when available.",
"codexFastServiceTierLabel": "Codex fast service tier",
"codexWeeklyToggleTitle": "Codex Weekly Toggle Title",
"codexWeeklyToggleTitle": "Track Codex weekly quota for this connection",
"compatUpstreamHeaderNamePlaceholder": "Compat Upstream Header Name Placeholder",
"compatUpstreamHeaderValuePlaceholder": "Compat Upstream Header Value Placeholder",
"compatible": "Compatible",
@@ -2749,8 +2750,8 @@
"consoleApiKeyOracleHint": "Console Api Key Oracle Hint",
"consoleApiKeyOracleLabel": "Console Api Key Oracle Label",
"consoleApiKeyOraclePlaceholder": "Console Api Key Oracle Placeholder",
"cpaModeDisabledTitle": "Cpa Mode Disabled Title",
"cpaModeEnabledTitle": "Cpa Mode Enabled Title",
"cpaModeDisabledTitle": "CLIProxyAPI compatibility mode is disabled",
"cpaModeEnabledTitle": "CLIProxyAPI compatibility mode is enabled",
"customUserAgentHint": "Custom User Agent Hint",
"customUserAgentLabel": "Custom User Agent Label",
"databricksBaseUrlHint": "Databricks Base Url Hint",
@@ -2811,18 +2812,18 @@
"sessionCookieLabel": "Session Cookie Label",
"showEmail": "Show Email",
"snowflakeBaseUrlHint": "Snowflake Base Url Hint",
"supportedEndpointAudio": "Supported Endpoint Audio",
"supportedEndpointChat": "Supported Endpoint Chat",
"supportedEndpointEmbeddings": "Supported Endpoint Embeddings",
"supportedEndpointImages": "Supported Endpoint Images",
"supportedEndpointsLabel": "Supported Endpoints Label",
"supportedEndpointAudio": "Audio",
"supportedEndpointChat": "Chat",
"supportedEndpointEmbeddings": "Embeddings",
"supportedEndpointImages": "Images",
"supportedEndpointsLabel": "Supported endpoints",
"tagGroupHint": "Tag Group Hint",
"tagGroupLabel": "Tag Group Label",
"tagGroupPlaceholder": "Tag Group Placeholder",
"testModel": "Test Model",
"testingModel": "Testing Model",
"toggleOffShort": "Toggle Off Short",
"toggleOnShort": "Toggle On Short",
"toggleOffShort": "Off",
"toggleOnShort": "On",
"tokenExpiredBadge": "Token Expired Badge",
"tokenExpiredTitle": "Token Expired Title",
"tokenExpiresSoonTitle": "Token Expires Soon Title",
@@ -4625,7 +4626,26 @@
"noData": "—",
"testSuccess": "✓ {ip}",
"testLatency": "{latency}ms",
"testFailure": "✗ {error}"
"testFailure": "✗ {error}",
"bulkImport": "Bulk Import",
"bulkImportTitle": "Bulk Import Proxies",
"bulkImportDescription": "Paste proxy profiles using pipe-delimited format. One proxy per line. Existing proxies (same host + port) will be updated.",
"bulkImportParse": "Parse",
"bulkImportImport": "Import {count} Proxies",
"bulkImportImporting": "Importing...",
"bulkImportParsed": "{count} proxies parsed",
"bulkImportSkipped": "{count} lines skipped",
"bulkImportParseErrors": "{count} errors",
"bulkImportNoValidEntries": "No valid entries found. Check the format and try again.",
"bulkImportSuccess": "Import complete: {created} created, {updated} updated, {failed} failed",
"bulkImportErrorLine": "Line {line}: {reason}",
"bulkImportMaxExceeded": "Maximum 100 proxies per import",
"bulkImportPreview": "Preview",
"bulkImportErrorMissingName": "Missing NAME",
"bulkImportErrorMissingHost": "Missing HOST",
"bulkImportErrorInvalidPort": "Invalid PORT (must be 1-65535)",
"bulkImportErrorInvalidType": "Invalid TYPE (use http, https, or socks5)",
"bulkImportErrorInvalidStatus": "Invalid STATUS (use active or inactive)"
},
"playground": {
"title": "Model Playground",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Temas",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Teemat",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Thèmes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Témák",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Temas",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Темы",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "ธีมส์",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Temalar",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "Themes",

View File

@@ -270,7 +270,7 @@
"editCombo": "Edit Combo",
"testResults": "Test Results",
"searchQuery": "Search Query",
"addCcCompatible": "Add Cc Compatible",
"addCcCompatible": "添加 CC 兼容",
"duplicate": "Duplicate",
"createCombo": "Create Combo",
"searchTypeWeb": "Search Type Web",
@@ -344,7 +344,7 @@
"signatureDefaults": "Signature Defaults",
"errorCreating": "Error Creating",
"timeRangeYear": "Time Range Year",
"compatibleLabel": "Compatible Label",
"compatibleLabel": "兼容",
"cloudDisabledSuccess": "Cloud Disabled Success",
"deleteConfirm": "Delete Confirm",
"check": "Check",
@@ -511,7 +511,7 @@
"comboUpdated": "Combo Updated",
"weighted": "Weighted",
"providers": "Providers",
"ccCompatibleLabel": "Cc Compatible Label",
"ccCompatibleLabel": "CC 兼容",
"noFallbackChainsDesc": "No Fallback Chains Desc",
"yesImport": "Yes Import",
"lockoutsAutoRefreshHint": "Lockouts Auto Refresh Hint",
@@ -734,7 +734,8 @@
"logoPreview": "Logo Preview",
"themeCustom": "Theme Custom",
"hideHealthLogsDesc": "Hide Health Logs Desc",
"faviconPreview": "Favicon Preview"
"faviconPreview": "Favicon Preview",
"changelog": "Changelog"
},
"themesPage": {
"title": "主题",
@@ -2641,7 +2642,7 @@
"accountIdLabel": "Account Id Label",
"accountIdPlaceholder": "Account Id Placeholder",
"addAnotherApiKey": "Add Another Api Key",
"addCcCompatible": "Add Cc Compatible",
"addCcCompatible": "添加 CC 兼容",
"aggregatorsGateways": "Aggregators Gateways",
"apiFormatLabel": "Api Format Label",
"apiKeyOptionalHint": "Api Key Optional Hint",
@@ -2658,27 +2659,27 @@
"bailianBaseUrlHint": "Bailian Base Url Hint",
"blackboxWebCookieHint": "Blackbox Web Cookie Hint",
"blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder",
"blockClaudeExtraUsageDescription": "Block Claude Extra Usage Description",
"blockClaudeExtraUsageLabel": "Block Claude Extra Usage Label",
"ccCompatibleBaseUrlHint": "Cc Compatible Base Url Hint",
"ccCompatibleBaseUrlPlaceholder": "Cc Compatible Base Url Placeholder",
"ccCompatibleChatPathHint": "Cc Compatible Chat Path Hint",
"ccCompatibleContext1mDescription": "Cc Compatible Context1M Description",
"ccCompatibleContext1mLabel": "Cc Compatible Context1M Label",
"ccCompatibleDetailsTitle": "Cc Compatible Details Title",
"ccCompatibleLabel": "Cc Compatible Label",
"ccCompatibleModelsDescription": "Cc Compatible Models Description",
"ccCompatibleNameHint": "Cc Compatible Name Hint",
"ccCompatibleNamePlaceholder": "Cc Compatible Name Placeholder",
"ccCompatiblePrefixHint": "Cc Compatible Prefix Hint",
"ccCompatiblePrefixPlaceholder": "Cc Compatible Prefix Placeholder",
"ccCompatibleValidationHint": "Cc Compatible Validation Hint",
"claudeExtraUsageShort": "Claude Extra Usage Short",
"claudeExtraUsageToggleTitle": "Claude Extra Usage Toggle Title",
"codex5hToggleTitle": "Codex5H Toggle Title",
"blockClaudeExtraUsageDescription": "隐藏部分 Provider 返回的重复 Claude 额外用量记录,避免和主 token 统计重复。",
"blockClaudeExtraUsageLabel": "屏蔽重复 Claude 用量",
"ccCompatibleBaseUrlHint": "Claude Code 专用中转站的 Base URL不要包含 /messages。",
"ccCompatibleBaseUrlPlaceholder": "https://relay.example.com/v1",
"ccCompatibleChatPathHint": "默认使用 Claude Code 严格的 Messages API 路径。仅在中转站文档要求时修改。",
"ccCompatibleContext1mDescription": "当所选 Claude 模型支持时,添加 context-1m beta header。",
"ccCompatibleContext1mLabel": "启用 1M context beta",
"ccCompatibleDetailsTitle": "CC 兼容中转站详情",
"ccCompatibleLabel": "CC 兼容",
"ccCompatibleModelsDescription": "CC 兼容中转站不提供模型列表。请添加该中转站接受的 Claude 模型 ID。",
"ccCompatibleNameHint": "这个 Claude Code 专用中转站的显示名称。",
"ccCompatibleNamePlaceholder": "CC 中转站生产环境",
"ccCompatiblePrefixHint": "用于 prefix/model-id 这类模型别名。",
"ccCompatiblePrefixPlaceholder": "cc",
"ccCompatibleValidationHint": "这个 Provider 只适用于仅向 Claude Code 客户端提供服务的中转站。OmniRoute 会把任何进入的请求改写为 Claude Code 兼容的传输格式,以通过这些中转站的验证。如果你只是想使用 Claude Code CLI或者不清楚这类中转站是什么请使用普通 Anthropic-compatible Provider。",
"claudeExtraUsageShort": "额外用量",
"claudeExtraUsageToggleTitle": "为此连接屏蔽 Claude 额外用量统计",
"codex5hToggleTitle": "为此连接跟踪 Codex 5 小时配额",
"codexFastServiceTierDescription": "可用时为 Codex 请求使用 priority 服务层。",
"codexFastServiceTierLabel": "Codex 快速服务层",
"codexWeeklyToggleTitle": "Codex Weekly Toggle Title",
"codexWeeklyToggleTitle": "为此连接跟踪 Codex 周配额",
"compatUpstreamHeaderNamePlaceholder": "Compat Upstream Header Name Placeholder",
"compatUpstreamHeaderValuePlaceholder": "Compat Upstream Header Value Placeholder",
"compatible": "Compatible",
@@ -2686,8 +2687,8 @@
"consoleApiKeyOracleHint": "Console Api Key Oracle Hint",
"consoleApiKeyOracleLabel": "Console Api Key Oracle Label",
"consoleApiKeyOraclePlaceholder": "Console Api Key Oracle Placeholder",
"cpaModeDisabledTitle": "Cpa Mode Disabled Title",
"cpaModeEnabledTitle": "Cpa Mode Enabled Title",
"cpaModeDisabledTitle": "CLIProxyAPI 兼容模式已关闭",
"cpaModeEnabledTitle": "CLIProxyAPI 兼容模式已开启",
"customUserAgentHint": "Custom User Agent Hint",
"customUserAgentLabel": "Custom User Agent Label",
"databricksBaseUrlHint": "Databricks Base Url Hint",
@@ -2748,18 +2749,18 @@
"sessionCookieLabel": "Session Cookie Label",
"showEmail": "Show Email",
"snowflakeBaseUrlHint": "Snowflake Base Url Hint",
"supportedEndpointAudio": "Supported Endpoint Audio",
"supportedEndpointChat": "Supported Endpoint Chat",
"supportedEndpointEmbeddings": "Supported Endpoint Embeddings",
"supportedEndpointImages": "Supported Endpoint Images",
"supportedEndpointsLabel": "Supported Endpoints Label",
"supportedEndpointAudio": "音频",
"supportedEndpointChat": "聊天",
"supportedEndpointEmbeddings": "Embeddings",
"supportedEndpointImages": "图像",
"supportedEndpointsLabel": "支持的端点",
"tagGroupHint": "Tag Group Hint",
"tagGroupLabel": "Tag Group Label",
"tagGroupPlaceholder": "Tag Group Placeholder",
"testModel": "Test Model",
"testingModel": "Testing Model",
"toggleOffShort": "Toggle Off Short",
"toggleOnShort": "Toggle On Short",
"toggleOffShort": "",
"toggleOnShort": "",
"tokenExpiredBadge": "Token Expired Badge",
"tokenExpiredTitle": "Token Expired Title",
"tokenExpiresSoonTitle": "Token Expires Soon Title",

View File

@@ -92,6 +92,12 @@ const RENAMED_MIGRATION_COMPATIBILITY = [
toVersion: "029",
toName: "provider_connection_max_concurrent",
},
{
fromVersion: "032",
fromName: "create_reasoning_cache",
toVersion: "033",
toName: "create_reasoning_cache",
},
] as const;
const PHYSICAL_SCHEMA_SENTINELS = [
@@ -211,6 +217,20 @@ function applyApiKeyLifecycleMigration(db: Database.Database): void {
`);
}
function isSearchRequestTypeMigration(migration: { version: string; name: string }): boolean {
return migration.version === "007";
}
function applySearchRequestTypeMigration(db: Database.Database): void {
ensureColumn(
db,
"call_logs",
"request_type",
"ALTER TABLE call_logs ADD COLUMN request_type TEXT DEFAULT NULL"
);
db.exec("CREATE INDEX IF NOT EXISTS idx_call_logs_request_type ON call_logs(request_type);");
}
function inferPhysicalSchemaBaseline(db: Database.Database): {
version: string;
description: string;
@@ -477,6 +497,8 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole
const applyMigration = db.transaction(() => {
if (isApiKeyLifecycleMigration(migration)) {
applyApiKeyLifecycleMigration(db);
} else if (isSearchRequestTypeMigration(migration)) {
applySearchRequestTypeMigration(db);
} else {
const sql = fs.readFileSync(migration.path, "utf-8");
db.exec(sql);

View File

@@ -192,6 +192,32 @@ export async function createProxy(payload: ProxyPayload) {
return getProxyById(id, { includeSecrets: false });
}
/**
* Upsert a proxy by host+port.
* If a proxy with the same host and port already exists, update it.
* Otherwise, create a new one. Used by the bulk import feature.
*/
export async function upsertProxy(payload: ProxyPayload): Promise<{
proxy: ProxyRegistryRecord | null;
action: "created" | "updated";
}> {
const db = getDbInstance();
const host = (payload.host || "").trim();
const port = Number(payload.port);
const existing = db
.prepare("SELECT id FROM proxy_registry WHERE host = ? AND port = ? LIMIT 1")
.get(host, port) as { id?: string } | undefined;
if (existing?.id) {
const updated = await updateProxy(existing.id, payload);
return { proxy: updated, action: "updated" };
}
const created = await createProxy(payload);
return { proxy: created, action: "created" };
}
export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
const db = getDbInstance();
const existing = await getProxyById(id, { includeSecrets: true });

View File

@@ -54,6 +54,8 @@ export async function getSettings() {
antigravitySignatureCacheMode: "enabled",
requireLogin: true,
hiddenSidebarItems: [],
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: false,
comboConfigMode: "guided",
alwaysPreserveClientCache: "auto",
idempotencyWindowMs: 5000,

View File

@@ -149,6 +149,7 @@ export {
getProxyById,
createProxy,
updateProxy,
upsertProxy,
deleteProxyById,
getProxyAssignments,
getProxyWhereUsed,

View File

@@ -246,7 +246,7 @@ export const CURSOR_CONFIG = {
agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode
// Client metadata
clientVersion: "3.1.15",
clientVersion: "3.2.14",
clientType: "ide",
// Token storage locations (for user reference)
tokenStoragePaths: {

View File

@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export function PwaRegister() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}
navigator.serviceWorker.register("/sw.js").catch(() => {
// Ignore registration failures to avoid blocking app rendering.
});
}, []);
return null;
}

View File

@@ -1,4 +1,53 @@
import { CLI_TOOLS } from "./cliTools";
import { normalizeCliCompatProviderId } from "../utils/cliCompat";
export { normalizeCliCompatProviderId };
export const IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS = [
"claude",
"codex",
"github",
"antigravity",
"qwen",
] as const;
export const CLI_COMPAT_DISPLAY_PROVIDER_IDS = [
"claude",
"codex",
"copilot",
"antigravity",
"qwen",
] as const;
/**
* Known CLI/tool providers that are intentionally not exposed as CLI Fingerprint toggles yet.
*
* This setting controls the generic `applyFingerprint()` pipeline (header/body ordering plus
* optional CLI User-Agent overrides). Do not expose providers here just because they have a
* CLI Tools card or a provider integration:
*
* - Kiro and Cursor already apply their native parity inside custom executors, so a toggle would
* be misleading unless it controls additional behavior.
* - Droid, OpenClaw, Windsurf and Hermes are CLI tool setup guides/settings, not upstream provider
* fingerprints handled by OmniRoute.
* - Cline, Kilo Code, OpenCode and Kimi Coding have real provider/backend integrations, but no
* captured `CLI_FINGERPRINTS` entry is wired to `applyFingerprint()` yet.
*
* Keep this list as documentation for intentionally omitted candidates. When adding a provider to
* the visible toggle list, also add a real `CLI_FINGERPRINTS` entry or wire its custom executor.
*/
export const CLI_COMPAT_OMITTED_PROVIDER_IDS = [
"kiro",
"cursor",
"droid",
"openclaw",
"windsurf",
"hermes",
"cline",
"kilocode",
"opencode",
"kimi-coding",
] as const;
/**
* Provider IDs toggled in Settings -> CLI Fingerprint.
@@ -13,17 +62,12 @@ const TOOL_ID_TO_PROVIDER_ID: Record<string, string> = {
};
const DERIVED_PROVIDER_IDS = Object.values(CLI_TOOLS)
.map((tool: any) => TOOL_ID_TO_PROVIDER_ID[tool.id] ?? tool.id)
.map((tool: any) => normalizeCliCompatProviderId(TOOL_ID_TO_PROVIDER_ID[tool.id] ?? tool.id))
// "continue" currently has no provider id in AI_PROVIDERS
.filter((providerId) => providerId !== "continue" && providerId !== "amp");
const LEGACY_PROVIDER_IDS = [
// Keep to avoid breaking setups that saved old IDs
"copilot",
"kimi-coding",
"qwen",
];
.filter((providerId) => IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS.includes(providerId as any));
export const CLI_COMPAT_PROVIDER_IDS = Array.from(
new Set([...DERIVED_PROVIDER_IDS, ...LEGACY_PROVIDER_IDS])
new Set([...DERIVED_PROVIDER_IDS, ...IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS])
);
export const CLI_COMPAT_TOGGLE_IDS = Array.from(new Set(CLI_COMPAT_DISPLAY_PROVIDER_IDS));

View File

@@ -22,6 +22,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"settings",
"docs",
"issues",
"changelog",
] as const;
export type HideableSidebarItemId = (typeof HIDEABLE_SIDEBAR_ITEM_IDS)[number];
@@ -92,6 +93,7 @@ const HELP_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
icon: "bug_report",
external: true,
},
{ id: "changelog", href: "/dashboard/changelog", i18nKey: "changelog", icon: "campaign" },
];
export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [

View File

@@ -0,0 +1,17 @@
export function normalizeClaudeBaseUrl(value: string): string {
return String(value || "")
.trim()
.replace(/\/+$/, "");
}
export function getStoredClaudeAuthValue(
env: Record<string, unknown> | null | undefined
): string | null {
if (!env || typeof env !== "object") return null;
const authValue = env.ANTHROPIC_AUTH_TOKEN ?? env.ANTHROPIC_API_KEY;
if (typeof authValue !== "string") return null;
const trimmed = authValue.trim();
return trimmed.length > 0 ? trimmed : null;
}

View File

@@ -0,0 +1,3 @@
export function normalizeCliCompatProviderId(providerId: string): string {
return providerId.toLowerCase() === "copilot" ? "github" : providerId.toLowerCase();
}

View File

@@ -117,9 +117,23 @@ export async function getConsistentMachineId(salt = null) {
const cryptoFallback = await import("crypto");
return cryptoFallback.randomUUID();
} catch {
if (typeof globalThis !== "undefined" && globalThis.crypto && globalThis.crypto.randomUUID) {
return globalThis.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
let r = 0;
if (
typeof globalThis !== "undefined" &&
globalThis.crypto &&
globalThis.crypto.getRandomValues
) {
const arr = new Uint8Array(1);
globalThis.crypto.getRandomValues(arr);
r = arr[0] % 16;
} else {
r = (Date.now() % 16) | 0;
}
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
@@ -140,9 +154,23 @@ export async function getRawMachineId() {
const cryptoFallback = await import("crypto");
return cryptoFallback.randomUUID();
} catch {
if (typeof globalThis !== "undefined" && globalThis.crypto && globalThis.crypto.randomUUID) {
return globalThis.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
let r = 0;
if (
typeof globalThis !== "undefined" &&
globalThis.crypto &&
globalThis.crypto.getRandomValues
) {
const arr = new Uint8Array(1);
globalThis.crypto.getRandomValues(arr);
r = arr[0] % 16;
} else {
r = (Date.now() % 16) | 0;
}
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}

View File

@@ -0,0 +1,53 @@
import { z } from "zod";
export const NEWS_JSON_URL =
"https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/news.json";
export const CHANGELOG_RAW_URL =
"https://raw.githubusercontent.com/diegosouzapw/OmniRoute/main/CHANGELOG.md";
export const CHANGELOG_GITHUB_URL =
"https://github.com/diegosouzapw/OmniRoute/blob/main/CHANGELOG.md";
const activeNewsSchema = z.object({
active: z.literal(true),
title: z.string().trim().min(1).max(120),
message: z.string().trim().min(1).max(600),
link: z.string().url().optional(),
linkLabel: z.string().trim().min(1).max(80).optional(),
icon: z
.string()
.trim()
.regex(/^[a-z0-9_]+$/)
.optional(),
});
const inactiveNewsSchema = z
.object({
active: z.literal(false),
})
.passthrough();
const newsPayloadSchema = z.discriminatedUnion("active", [activeNewsSchema, inactiveNewsSchema]);
export type NewsAnnouncement = z.infer<typeof activeNewsSchema>;
export function parseActiveNewsPayload(payload: unknown): NewsAnnouncement | null {
const parsed = newsPayloadSchema.safeParse(payload);
if (!parsed.success || parsed.data.active !== true) return null;
return parsed.data;
}
export function getLatestChangelogMarkdown(markdown: string, limit = 10): string {
const parts = markdown.split(/^##\s+\[/gm);
if (parts.length <= 1) {
const truncated = markdown.slice(0, 5000).trimEnd();
return markdown.length > 5000 ? `${truncated}\n\n...` : truncated;
}
const header = parts[0].trimEnd();
const versions = parts
.slice(1, limit + 1)
.map((part) => `## [${part.trimEnd()}`)
.join("\n\n");
return [header, versions].filter(Boolean).join("\n\n");
}

View File

@@ -1157,6 +1157,15 @@ export const updateProxyRegistrySchema = createProxyRegistrySchema.partial().ext
id: z.string().trim().min(1, "id is required"),
});
export const bulkImportProxiesSchema = z
.object({
items: z
.array(createProxyRegistrySchema)
.min(1, "At least one proxy is required")
.max(100, "Maximum 100 proxies per import"),
})
.strict();
export const proxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),

View File

@@ -45,6 +45,8 @@ export const updateSettingsSchema = z.object({
setupComplete: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
hideEndpointCloudflaredTunnel: z.boolean().optional(),
hideEndpointTailscaleFunnel: z.boolean().optional(),
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),

View File

@@ -20,6 +20,8 @@ export interface Settings {
maxRetryIntervalSec: number;
jwtSecret?: string;
hideHealthCheckLogs?: boolean;
hideEndpointCloudflaredTunnel?: boolean;
hideEndpointTailscaleFunnel?: boolean;
hiddenSidebarItems?: HideableSidebarItemId[];
resilienceSettings?: ResilienceSettings;
}