omniroute 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/app/.next/BUILD_ID +1 -1
  2. package/app/.next/build-manifest.json +2 -2
  3. package/app/.next/prerender-manifest.json +3 -3
  4. package/app/.next/server/app/(dashboard)/dashboard/a2a/page_client-reference-manifest.js +1 -1
  5. package/app/.next/server/app/(dashboard)/dashboard/agents/page_client-reference-manifest.js +1 -1
  6. package/app/.next/server/app/(dashboard)/dashboard/analytics/page_client-reference-manifest.js +1 -1
  7. package/app/.next/server/app/(dashboard)/dashboard/api-manager/page_client-reference-manifest.js +1 -1
  8. package/app/.next/server/app/(dashboard)/dashboard/audit-log/page_client-reference-manifest.js +1 -1
  9. package/app/.next/server/app/(dashboard)/dashboard/auto-combo/page_client-reference-manifest.js +1 -1
  10. package/app/.next/server/app/(dashboard)/dashboard/cli-tools/page_client-reference-manifest.js +1 -1
  11. package/app/.next/server/app/(dashboard)/dashboard/combos/page_client-reference-manifest.js +1 -1
  12. package/app/.next/server/app/(dashboard)/dashboard/costs/page_client-reference-manifest.js +1 -1
  13. package/app/.next/server/app/(dashboard)/dashboard/endpoint/page_client-reference-manifest.js +1 -1
  14. package/app/.next/server/app/(dashboard)/dashboard/health/page_client-reference-manifest.js +1 -1
  15. package/app/.next/server/app/(dashboard)/dashboard/limits/page_client-reference-manifest.js +1 -1
  16. package/app/.next/server/app/(dashboard)/dashboard/logs/page_client-reference-manifest.js +1 -1
  17. package/app/.next/server/app/(dashboard)/dashboard/mcp/page_client-reference-manifest.js +1 -1
  18. package/app/.next/server/app/(dashboard)/dashboard/media/page_client-reference-manifest.js +1 -1
  19. package/app/.next/server/app/(dashboard)/dashboard/onboarding/page_client-reference-manifest.js +1 -1
  20. package/app/.next/server/app/(dashboard)/dashboard/page_client-reference-manifest.js +1 -1
  21. package/app/.next/server/app/(dashboard)/dashboard/playground/page_client-reference-manifest.js +1 -1
  22. package/app/.next/server/app/(dashboard)/dashboard/profile/page_client-reference-manifest.js +1 -1
  23. package/app/.next/server/app/(dashboard)/dashboard/providers/[id]/page_client-reference-manifest.js +1 -1
  24. package/app/.next/server/app/(dashboard)/dashboard/providers/new/page_client-reference-manifest.js +1 -1
  25. package/app/.next/server/app/(dashboard)/dashboard/providers/page_client-reference-manifest.js +1 -1
  26. package/app/.next/server/app/(dashboard)/dashboard/search-tools/page_client-reference-manifest.js +1 -1
  27. package/app/.next/server/app/(dashboard)/dashboard/settings/page_client-reference-manifest.js +1 -1
  28. package/app/.next/server/app/(dashboard)/dashboard/settings/pricing/page_client-reference-manifest.js +1 -1
  29. package/app/.next/server/app/(dashboard)/dashboard/translator/page_client-reference-manifest.js +1 -1
  30. package/app/.next/server/app/(dashboard)/dashboard/usage/page_client-reference-manifest.js +1 -1
  31. package/app/.next/server/app/400/page_client-reference-manifest.js +1 -1
  32. package/app/.next/server/app/401/page_client-reference-manifest.js +1 -1
  33. package/app/.next/server/app/403/page_client-reference-manifest.js +1 -1
  34. package/app/.next/server/app/408/page_client-reference-manifest.js +1 -1
  35. package/app/.next/server/app/429/page_client-reference-manifest.js +1 -1
  36. package/app/.next/server/app/500/page_client-reference-manifest.js +1 -1
  37. package/app/.next/server/app/502/page_client-reference-manifest.js +1 -1
  38. package/app/.next/server/app/503/page_client-reference-manifest.js +1 -1
  39. package/app/.next/server/app/_global-error.html +2 -2
  40. package/app/.next/server/app/_global-error.rsc +1 -1
  41. package/app/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  42. package/app/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  43. package/app/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  44. package/app/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  45. package/app/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  46. package/app/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  47. package/app/.next/server/app/callback/page_client-reference-manifest.js +1 -1
  48. package/app/.next/server/app/docs/page_client-reference-manifest.js +1 -1
  49. package/app/.next/server/app/forbidden/page_client-reference-manifest.js +1 -1
  50. package/app/.next/server/app/forgot-password/page_client-reference-manifest.js +1 -1
  51. package/app/.next/server/app/landing/page_client-reference-manifest.js +1 -1
  52. package/app/.next/server/app/login/page_client-reference-manifest.js +1 -1
  53. package/app/.next/server/app/maintenance/page_client-reference-manifest.js +1 -1
  54. package/app/.next/server/app/offline/page_client-reference-manifest.js +1 -1
  55. package/app/.next/server/app/page_client-reference-manifest.js +1 -1
  56. package/app/.next/server/app/privacy/page_client-reference-manifest.js +1 -1
  57. package/app/.next/server/app/status/page_client-reference-manifest.js +1 -1
  58. package/app/.next/server/app/terms/page_client-reference-manifest.js +1 -1
  59. package/app/.next/server/chunks/[root-of-the-server]__051203a6._.js +2 -2
  60. package/app/.next/server/chunks/[root-of-the-server]__0891af92._.js +1 -1
  61. package/app/.next/server/chunks/[root-of-the-server]__1f2b0d89._.js +1 -1
  62. package/app/.next/server/chunks/[root-of-the-server]__61d78f9d._.js +2 -2
  63. package/app/.next/server/chunks/[root-of-the-server]__6e52619e._.js +1 -1
  64. package/app/.next/server/chunks/[root-of-the-server]__afddb4ce._.js +1 -1
  65. package/app/.next/server/chunks/[root-of-the-server]__e27a89bd._.js +1 -1
  66. package/app/.next/server/chunks/[root-of-the-server]__fd2fbc93._.js +1 -1
  67. package/app/.next/server/chunks/_05c48915._.js +1 -1
  68. package/app/.next/server/chunks/_06515a8a._.js +1 -1
  69. package/app/.next/server/chunks/_2115d8de._.js +1 -1
  70. package/app/.next/server/chunks/_3ac953eb._.js +1 -1
  71. package/app/.next/server/chunks/_4b8fd853._.js +1 -1
  72. package/app/.next/server/chunks/_68683848._.js +1 -1
  73. package/app/.next/server/chunks/_6f1b3c3f._.js +1 -1
  74. package/app/.next/server/chunks/_ee9b677b._.js +1 -1
  75. package/app/.next/server/chunks/_efd5ede2._.js +2 -2
  76. package/app/.next/server/chunks/_ffda39da._.js +1 -1
  77. package/app/.next/server/chunks/open-sse_translator_index_ts_f5fd0821._.js +1 -1
  78. package/app/.next/server/chunks/src_6320c728._.js +1 -1
  79. package/app/.next/server/chunks/ssr/[root-of-the-server]__9ef96d20._.js +1 -1
  80. package/app/.next/server/chunks/ssr/[root-of-the-server]__a6942102._.js +1 -1
  81. package/app/.next/server/chunks/ssr/_f3674909._.js +1 -1
  82. package/app/.next/server/chunks/ssr/src_a82a42f9._.js +1 -1
  83. package/app/.next/server/chunks/ssr/src_app_(dashboard)_dashboard_936a9ee0._.js +1 -1
  84. package/app/.next/server/chunks/ssr/src_app_(dashboard)_dashboard_settings_9e20fb8d._.js +1 -1
  85. package/app/.next/server/pages/500.html +2 -2
  86. package/app/.next/server/server-reference-manifest.js +1 -1
  87. package/app/.next/server/server-reference-manifest.json +1 -1
  88. package/app/.next/static/chunks/26aeb12cf79339b1.js +1 -0
  89. package/app/.next/static/chunks/{80658e0c2b2c7004.js → 633a312ddcf9fa11.js} +1 -1
  90. package/app/.next/static/chunks/7df0b3c357097db5.js +1 -0
  91. package/app/.next/static/chunks/a051245b46459ad2.css +1 -0
  92. package/app/.next/static/chunks/d188e358e1ec0a7d.js +1 -0
  93. package/app/.next/static/chunks/{4e3fe685e3218d24.js → fa9e2a946d2cdbc7.js} +1 -1
  94. package/app/CHANGELOG.md +122 -68
  95. package/app/docs/i18n/ru/README.md +3 -3
  96. package/app/docs/openapi.yaml +1 -1
  97. package/app/open-sse/handlers/chatCore.ts +9 -2
  98. package/app/open-sse/handlers/responseTranslator.ts +13 -3
  99. package/app/open-sse/services/usage.ts +22 -9
  100. package/app/open-sse/translator/request/openai-to-claude.ts +10 -2
  101. package/app/package-lock.json +2 -2
  102. package/app/package.json +1 -1
  103. package/app/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +198 -63
  104. package/app/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +123 -19
  105. package/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +22 -24
  106. package/app/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +5 -0
  107. package/app/src/lib/providers/validation.ts +21 -0
  108. package/app/src/shared/components/ProxyConfigModal.tsx +42 -13
  109. package/package.json +1 -1
  110. package/app/.next/static/chunks/594bdf69c19b5310.js +0 -1
  111. package/app/.next/static/chunks/643428fcd3f9bb51.js +0 -1
  112. package/app/.next/static/chunks/8efe14530c87d9f6.css +0 -1
  113. package/app/.next/static/chunks/d0cff7a6c3a66c7d.js +0 -1
  114. /package/app/.next/static/{cQZnxkFFvb8O7T4x7OSsG → bF7pUIj1e9VsHgSW5x9WX}/_buildManifest.js +0 -0
  115. /package/app/.next/static/{cQZnxkFFvb8O7T4x7OSsG → bF7pUIj1e9VsHgSW5x9WX}/_clientMiddlewareManifest.json +0 -0
  116. /package/app/.next/static/{cQZnxkFFvb8O7T4x7OSsG → bF7pUIj1e9VsHgSW5x9WX}/_ssgManifest.js +0 -0
@@ -802,6 +802,9 @@ export default function ProviderDetailPage() {
802
802
  const userDismissed = useRef(false);
803
803
  const [proxyTarget, setProxyTarget] = useState(null);
804
804
  const [proxyConfig, setProxyConfig] = useState(null);
805
+ const [connProxyMap, setConnProxyMap] = useState<
806
+ Record<string, { proxy: any; level: string } | null>
807
+ >({});
805
808
  const [importingModels, setImportingModels] = useState(false);
806
809
  const [showImportModal, setShowImportModal] = useState(false);
807
810
  const [importProgress, setImportProgress] = useState({
@@ -938,18 +941,48 @@ export default function ProviderDetailPage() {
938
941
  useEffect(() => {
939
942
  fetchConnections();
940
943
  fetchAliases();
941
- // Load proxy config for visual indicators
944
+ // Load proxy config for visual indicators (provider-level button)
942
945
  fetch("/api/settings/proxy")
943
946
  .then((r) => (r.ok ? r.json() : null))
944
947
  .then((c) => setProxyConfig(c))
945
948
  .catch(() => {});
946
949
  }, [fetchConnections, fetchAliases]);
947
950
 
951
+ const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
952
+ if (!conns.length) return;
953
+ try {
954
+ const results = await Promise.all(
955
+ conns
956
+ .filter((c) => c.id)
957
+ .map((c) =>
958
+ fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
959
+ .then((r) => (r.ok ? r.json() : null))
960
+ .then((data) => [c.id!, data] as [string, any])
961
+ .catch(() => [c.id!, null] as [string, any])
962
+ )
963
+ );
964
+ const map: Record<string, { proxy: any; level: string } | null> = {};
965
+ for (const [id, data] of results) {
966
+ map[id] = data?.proxy ? data : null;
967
+ }
968
+ setConnProxyMap(map);
969
+ } catch {
970
+ // ignore
971
+ }
972
+ }, []);
973
+
948
974
  useEffect(() => {
949
975
  if (loading || isSearchProvider) return;
950
976
  fetchProviderModelMeta();
951
977
  }, [loading, isSearchProvider, fetchProviderModelMeta]);
952
978
 
979
+ // Load per-connection effective proxy (handles registry assignments)
980
+ useEffect(() => {
981
+ if (!loading && connections.length > 0) {
982
+ void loadConnProxies(connections);
983
+ }
984
+ }, [loading, connections, loadConnProxies]);
985
+
953
986
  // Auto-open Add Connection modal when no connections exist (better UX)
954
987
  // Only fires once on initial load, not on HMR remounts or after user dismissal
955
988
  useEffect(() => {
@@ -1930,68 +1963,153 @@ export default function ProviderDetailPage() {
1930
1963
  )}
1931
1964
  </div>
1932
1965
  ) : (
1933
- <div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
1934
- {connections
1935
- .sort((a, b) => (a.priority || 0) - (b.priority || 0))
1936
- .map((conn, index) => (
1937
- <ConnectionRow
1938
- key={conn.id}
1939
- connection={conn}
1940
- isOAuth={isOAuth}
1941
- isFirst={index === 0}
1942
- isLast={index === connections.length - 1}
1943
- onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
1944
- onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
1945
- onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
1946
- onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
1947
- isCodex={providerId === "codex"}
1948
- onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
1949
- onToggleCodexWeekly={(enabled) =>
1950
- handleToggleCodexLimit(conn.id, "useWeekly", enabled)
1951
- }
1952
- onRetest={() => handleRetestConnection(conn.id)}
1953
- isRetesting={retestingId === conn.id}
1954
- onEdit={() => {
1955
- setSelectedConnection(conn);
1956
- setShowEditModal(true);
1957
- }}
1958
- onDelete={() => handleDelete(conn.id)}
1959
- onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
1960
- onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
1961
- isRefreshing={refreshingId === conn.id}
1962
- onProxy={() =>
1963
- setProxyTarget({
1964
- level: "key",
1965
- id: conn.id,
1966
- label: conn.name || conn.email || conn.id,
1967
- })
1968
- }
1969
- hasProxy={
1970
- !!(
1971
- proxyConfig?.keys?.[conn.id] ||
1972
- proxyConfig?.providers?.[providerId] ||
1973
- proxyConfig?.global
1974
- )
1975
- }
1976
- proxySource={
1977
- proxyConfig?.keys?.[conn.id]
1978
- ? "key"
1979
- : proxyConfig?.providers?.[providerId]
1980
- ? "provider"
1981
- : proxyConfig?.global
1982
- ? "global"
1983
- : null
1984
- }
1985
- proxyHost={
1986
- (
1987
- proxyConfig?.keys?.[conn.id] ||
1988
- proxyConfig?.providers?.[providerId] ||
1989
- proxyConfig?.global
1990
- )?.host || null
1991
- }
1992
- />
1993
- ))}
1994
- </div>
1966
+ (() => {
1967
+ // Group connections by tag (providerSpecificData.tag)
1968
+ const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0));
1969
+ const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined);
1970
+
1971
+ if (!hasAnyTag) {
1972
+ // No tags — render flat list as before
1973
+ return (
1974
+ <div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
1975
+ {sorted.map((conn, index) => (
1976
+ <ConnectionRow
1977
+ key={conn.id}
1978
+ connection={conn}
1979
+ isOAuth={isOAuth}
1980
+ isFirst={index === 0}
1981
+ isLast={index === sorted.length - 1}
1982
+ onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
1983
+ onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
1984
+ onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
1985
+ onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
1986
+ isCodex={providerId === "codex"}
1987
+ onToggleCodex5h={(enabled) =>
1988
+ handleToggleCodexLimit(conn.id, "use5h", enabled)
1989
+ }
1990
+ onToggleCodexWeekly={(enabled) =>
1991
+ handleToggleCodexLimit(conn.id, "useWeekly", enabled)
1992
+ }
1993
+ onRetest={() => handleRetestConnection(conn.id)}
1994
+ isRetesting={retestingId === conn.id}
1995
+ onEdit={() => {
1996
+ setSelectedConnection(conn);
1997
+ setShowEditModal(true);
1998
+ }}
1999
+ onDelete={() => handleDelete(conn.id)}
2000
+ onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
2001
+ onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
2002
+ isRefreshing={refreshingId === conn.id}
2003
+ onProxy={() =>
2004
+ setProxyTarget({
2005
+ level: "key",
2006
+ id: conn.id,
2007
+ label: conn.name || conn.email || conn.id,
2008
+ })
2009
+ }
2010
+ hasProxy={!!connProxyMap[conn.id]?.proxy}
2011
+ proxySource={connProxyMap[conn.id]?.level || null}
2012
+ proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
2013
+ />
2014
+ ))}
2015
+ </div>
2016
+ );
2017
+ }
2018
+
2019
+ // Build ordered tag groups: untagged first, then alphabetically
2020
+ const groupMap = new Map<string, typeof sorted>();
2021
+ for (const conn of sorted) {
2022
+ const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
2023
+ if (!groupMap.has(tag)) groupMap.set(tag, []);
2024
+ groupMap.get(tag)!.push(conn);
2025
+ }
2026
+ const groupKeys = Array.from(groupMap.keys()).sort((a, b) => {
2027
+ if (a === "") return -1;
2028
+ if (b === "") return 1;
2029
+ return a.localeCompare(b);
2030
+ });
2031
+
2032
+ return (
2033
+ <div className="flex flex-col gap-0">
2034
+ {groupKeys.map((tag, gi) => {
2035
+ const groupConns = groupMap.get(tag)!;
2036
+ return (
2037
+ <div
2038
+ key={tag || "__untagged__"}
2039
+ className={
2040
+ gi > 0
2041
+ ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
2042
+ : ""
2043
+ }
2044
+ >
2045
+ {tag && (
2046
+ <div className="flex items-center gap-2 px-3 pt-2 pb-1">
2047
+ <span className="material-symbols-outlined text-[13px] text-text-muted/50">
2048
+ label
2049
+ </span>
2050
+ <span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
2051
+ {tag}
2052
+ </span>
2053
+ <div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
2054
+ <span className="text-[10px] text-text-muted/40">
2055
+ {groupConns.length}
2056
+ </span>
2057
+ </div>
2058
+ )}
2059
+ <div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
2060
+ {groupConns.map((conn, index) => (
2061
+ <ConnectionRow
2062
+ key={conn.id}
2063
+ connection={conn}
2064
+ isOAuth={isOAuth}
2065
+ isFirst={gi === 0 && index === 0}
2066
+ isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
2067
+ onMoveUp={() =>
2068
+ handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
2069
+ }
2070
+ onMoveDown={() =>
2071
+ handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
2072
+ }
2073
+ onToggleActive={(isActive) =>
2074
+ handleUpdateConnectionStatus(conn.id, isActive)
2075
+ }
2076
+ onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
2077
+ isCodex={providerId === "codex"}
2078
+ onToggleCodex5h={(enabled) =>
2079
+ handleToggleCodexLimit(conn.id, "use5h", enabled)
2080
+ }
2081
+ onToggleCodexWeekly={(enabled) =>
2082
+ handleToggleCodexLimit(conn.id, "useWeekly", enabled)
2083
+ }
2084
+ onRetest={() => handleRetestConnection(conn.id)}
2085
+ isRetesting={retestingId === conn.id}
2086
+ onEdit={() => {
2087
+ setSelectedConnection(conn);
2088
+ setShowEditModal(true);
2089
+ }}
2090
+ onDelete={() => handleDelete(conn.id)}
2091
+ onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
2092
+ onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
2093
+ isRefreshing={refreshingId === conn.id}
2094
+ onProxy={() =>
2095
+ setProxyTarget({
2096
+ level: "key",
2097
+ id: conn.id,
2098
+ label: conn.name || conn.email || conn.id,
2099
+ })
2100
+ }
2101
+ hasProxy={!!connProxyMap[conn.id]?.proxy}
2102
+ proxySource={connProxyMap[conn.id]?.level || null}
2103
+ proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
2104
+ />
2105
+ ))}
2106
+ </div>
2107
+ </div>
2108
+ );
2109
+ })}
2110
+ </div>
2111
+ );
2112
+ })()
1995
2113
  )}
1996
2114
  </Card>
1997
2115
 
@@ -2188,6 +2306,7 @@ export default function ProviderDetailPage() {
2188
2306
  level={proxyTarget.level}
2189
2307
  levelId={proxyTarget.id}
2190
2308
  levelLabel={proxyTarget.label}
2309
+ onSaved={() => void loadConnProxies(connections)}
2191
2310
  />
2192
2311
  )}
2193
2312
  {/* Import Progress Modal */}
@@ -4130,6 +4249,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
4130
4249
  baseUrl: "",
4131
4250
  region: "",
4132
4251
  validationModelId: "",
4252
+ tag: "",
4133
4253
  });
4134
4254
  const [testing, setTesting] = useState(false);
4135
4255
  const [testResult, setTestResult] = useState(null);
@@ -4159,6 +4279,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
4159
4279
  baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
4160
4280
  region: existingRegion || (isVertex ? defaultRegion : ""),
4161
4281
  validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
4282
+ tag: (connection.providerSpecificData?.tag as string) || "",
4162
4283
  });
4163
4284
  // Load existing extra keys from providerSpecificData
4164
4285
  const existing = connection.providerSpecificData?.extraApiKeys;
@@ -4282,6 +4403,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
4282
4403
  updates.providerSpecificData = {
4283
4404
  ...(connection.providerSpecificData || {}),
4284
4405
  extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
4406
+ tag: formData.tag.trim() || undefined,
4285
4407
  };
4286
4408
  if (formData.validationModelId) {
4287
4409
  updates.providerSpecificData.validationModelId = formData.validationModelId;
@@ -4292,6 +4414,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
4292
4414
  } else if (isVertex) {
4293
4415
  updates.providerSpecificData.region = formData.region;
4294
4416
  }
4417
+ } else {
4418
+ // Also persist tag for OAuth accounts
4419
+ updates.providerSpecificData = {
4420
+ ...(connection.providerSpecificData || {}),
4421
+ tag: formData.tag.trim() || undefined,
4422
+ };
4295
4423
  }
4296
4424
  const error = (await onSave(updates)) as void | unknown;
4297
4425
  if (error) {
@@ -4322,6 +4450,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
4322
4450
  onChange={(e) => setFormData({ ...formData, name: e.target.value })}
4323
4451
  placeholder={isOAuth ? t("accountName") : t("productionKey")}
4324
4452
  />
4453
+ <Input
4454
+ label="Tag / Group"
4455
+ value={formData.tag}
4456
+ onChange={(e) => setFormData({ ...formData, tag: e.target.value })}
4457
+ placeholder="e.g. personal, work, team-a"
4458
+ hint="Used to group accounts in the provider view"
4459
+ />
4325
4460
  {isOAuth && connection.email && (
4326
4461
  <div className="bg-sidebar/50 p-3 rounded-lg">
4327
4462
  <p className="text-sm text-text-muted mb-1">{t("email")}</p>
@@ -27,6 +27,14 @@ type HealthInfo = {
27
27
  lastSeenAt: string | null;
28
28
  };
29
29
 
30
+ type TestResult = {
31
+ success: boolean;
32
+ publicIp?: string;
33
+ latencyMs?: number;
34
+ country?: string;
35
+ error?: string;
36
+ };
37
+
30
38
  const EMPTY_FORM = {
31
39
  id: "",
32
40
  name: "",
@@ -51,6 +59,8 @@ export default function ProxyRegistryManager() {
51
59
 
52
60
  const [usageById, setUsageById] = useState<Record<string, UsageInfo>>({});
53
61
  const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
62
+ const [testById, setTestById] = useState<Record<string, TestResult | null>>({});
63
+ const [testingId, setTestingId] = useState<string | null>(null);
54
64
  const [migrating, setMigrating] = useState(false);
55
65
  const [bulkOpen, setBulkOpen] = useState(false);
56
66
  const [bulkSaving, setBulkSaving] = useState(false);
@@ -75,6 +85,36 @@ export default function ProxyRegistryManager() {
75
85
  }
76
86
  }, []);
77
87
 
88
+ const loadAllUsage = useCallback(async (proxyIds: string[]) => {
89
+ if (!proxyIds.length) return;
90
+ try {
91
+ const results = await Promise.all(
92
+ proxyIds.map((id) =>
93
+ fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
94
+ .then((r) => (r.ok ? r.json() : null))
95
+ .then((data) => {
96
+ const rawAssignments: Array<{ scope: string; scopeId: string | null }> =
97
+ Array.isArray(data?.items) ? data.items : [];
98
+ // Deduplicate by scope+scopeId — prevents double-counting when both
99
+ // a provider-scope and account-scope row exist for the same proxy
100
+ const seen = new Set<string>();
101
+ const assignments = rawAssignments.filter((a) => {
102
+ const key = `${a.scope}:${a.scopeId ?? ""}`;
103
+ if (seen.has(key)) return false;
104
+ seen.add(key);
105
+ return true;
106
+ });
107
+ return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
108
+ })
109
+ .catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
110
+ )
111
+ );
112
+ setUsageById(Object.fromEntries(results));
113
+ } catch {
114
+ // ignore
115
+ }
116
+ }, []);
117
+
78
118
  const load = useCallback(async () => {
79
119
  setLoading(true);
80
120
  setError(null);
@@ -86,15 +126,18 @@ export default function ProxyRegistryManager() {
86
126
  setItems([]);
87
127
  return;
88
128
  }
89
- setItems(Array.isArray(data?.items) ? data.items : []);
129
+ const loaded: ProxyItem[] = Array.isArray(data?.items) ? data.items : [];
130
+ setItems(loaded);
131
+ const ids = loaded.map((p) => p.id).filter(Boolean);
90
132
  void loadHealth();
133
+ void loadAllUsage(ids);
91
134
  } catch (e: any) {
92
135
  setError(e?.message || "Failed to load proxy registry");
93
136
  setItems([]);
94
137
  } finally {
95
138
  setLoading(false);
96
139
  }
97
- }, [loadHealth]);
140
+ }, [loadHealth, loadAllUsage]);
98
141
 
99
142
  useEffect(() => {
100
143
  void load();
@@ -130,22 +173,63 @@ export default function ProxyRegistryManager() {
130
173
  const loadUsage = async (proxyId: string) => {
131
174
  try {
132
175
  const res = await fetch(
133
- `/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`
176
+ `/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
134
177
  );
135
178
  const data = await res.json().catch(() => ({}));
136
179
  if (!res.ok) return;
180
+ const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray(
181
+ data?.items
182
+ )
183
+ ? data.items
184
+ : [];
185
+ const seen = new Set<string>();
186
+ const assignments = rawAssignments.filter((a) => {
187
+ const key = `${a.scope}:${a.scopeId ?? ""}`;
188
+ if (seen.has(key)) return false;
189
+ seen.add(key);
190
+ return true;
191
+ });
137
192
  setUsageById((prev) => ({
138
193
  ...prev,
139
- [proxyId]: {
140
- count: Number(data?.count || 0),
141
- assignments: Array.isArray(data?.assignments) ? data.assignments : [],
142
- },
194
+ [proxyId]: { count: assignments.length, assignments },
143
195
  }));
144
196
  } catch {
145
197
  // ignore usage loading errors in UI
146
198
  }
147
199
  };
148
200
 
201
+ const handleTestProxy = async (item: ProxyItem) => {
202
+ if (testingId) return;
203
+ setTestingId(item.id);
204
+ setTestById((prev) => ({ ...prev, [item.id]: null }));
205
+ try {
206
+ const res = await fetch("/api/settings/proxy/test", {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify({
210
+ proxy: {
211
+ type: item.type || "http",
212
+ host: item.host,
213
+ port: String(item.port || 8080),
214
+ },
215
+ }),
216
+ });
217
+ const data = await res.json().catch(() => ({}));
218
+ if (!res.ok) {
219
+ setTestById((prev) => ({
220
+ ...prev,
221
+ [item.id]: { success: false, error: data?.error?.message || "Test failed" },
222
+ }));
223
+ return;
224
+ }
225
+ setTestById((prev) => ({ ...prev, [item.id]: { success: true, ...data } }));
226
+ } catch (e: any) {
227
+ setTestById((prev) => ({ ...prev, [item.id]: { success: false, error: e?.message } }));
228
+ } finally {
229
+ setTestingId(null);
230
+ }
231
+ };
232
+
149
233
  const handleSave = async () => {
150
234
  if (!form.name.trim() || !form.host.trim()) {
151
235
  setError("Name and host are required");
@@ -378,27 +462,47 @@ export default function ProxyRegistryManager() {
378
462
  </span>
379
463
  </td>
380
464
  <td className="py-2 pr-3 text-xs text-text-muted">
381
- {health ? (
382
- <div className="flex flex-col gap-0.5">
383
- <span>{health.successRate ?? 0}% success</span>
384
- <span>{health.avgLatencyMs ?? "-"} ms avg</span>
385
- </div>
386
- ) : (
387
- "-"
388
- )}
465
+ <div className="flex flex-col gap-0.5">
466
+ {health ? (
467
+ <>
468
+ <span>{health.successRate ?? 0}% success</span>
469
+ <span>{health.avgLatencyMs ?? "-"} ms avg</span>
470
+ </>
471
+ ) : testById[item.id] ? (
472
+ testById[item.id]!.success ? (
473
+ <>
474
+ <span className="text-emerald-400">
475
+ ✓ {testById[item.id]!.publicIp}
476
+ </span>
477
+ {testById[item.id]!.latencyMs && (
478
+ <span>{testById[item.id]!.latencyMs}ms</span>
479
+ )}
480
+ </>
481
+ ) : (
482
+ <span className="text-red-400">
483
+ {testById[item.id]!.error || "failed"}
484
+ </span>
485
+ )
486
+ ) : (
487
+ <span>—</span>
488
+ )}
489
+ </div>
389
490
  </td>
390
491
  <td className="py-2 pr-3 text-xs text-text-muted">
391
- {usage ? `${usage.count} assignment(s)` : "-"}
492
+ {usageById[item.id] != null
493
+ ? `${usageById[item.id].count} assignment(s)`
494
+ : "—"}
392
495
  </td>
393
496
  <td className="py-2">
394
497
  <div className="flex items-center gap-1">
395
498
  <Button
396
499
  size="sm"
397
500
  variant="ghost"
398
- icon="visibility"
399
- onClick={() => void loadUsage(item.id)}
501
+ icon="speed"
502
+ onClick={() => void handleTestProxy(item)}
503
+ loading={testingId === item.id}
400
504
  >
401
- Usage
505
+ Test
402
506
  </Button>
403
507
  <Button
404
508
  size="sm"