diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 698be1e48b..b0cafebde2 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -125,11 +125,14 @@ export function translateNonStreamingResponse( typeof itemObj.arguments === "string" ? itemObj.arguments : JSON.stringify(itemObj.arguments || {}); + const rawName = toString(itemObj.name); + // Strip Claude OAuth proxy_ prefix using toolNameMap (mirrors tool_use fix for #605) + const resolvedName = toolNameMap?.get(rawName) ?? rawName; toolCalls.push({ id: callId, type: "function", function: { - name: toString(itemObj.name), + name: resolvedName, arguments: fnArgs, }, }); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index f1be9646e3..c3c9a0554b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -802,6 +802,9 @@ export default function ProviderDetailPage() { const userDismissed = useRef(false); const [proxyTarget, setProxyTarget] = useState(null); const [proxyConfig, setProxyConfig] = useState(null); + const [connProxyMap, setConnProxyMap] = useState< + Record + >({}); const [importingModels, setImportingModels] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [importProgress, setImportProgress] = useState({ @@ -938,18 +941,48 @@ export default function ProviderDetailPage() { useEffect(() => { fetchConnections(); fetchAliases(); - // Load proxy config for visual indicators + // Load proxy config for visual indicators (provider-level button) fetch("/api/settings/proxy") .then((r) => (r.ok ? r.json() : null)) .then((c) => setProxyConfig(c)) .catch(() => {}); }, [fetchConnections, fetchAliases]); + const loadConnProxies = useCallback(async (conns: { id?: string }[]) => { + if (!conns.length) return; + try { + const results = await Promise.all( + conns + .filter((c) => c.id) + .map((c) => + fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => [c.id!, data] as [string, any]) + .catch(() => [c.id!, null] as [string, any]) + ) + ); + const map: Record = {}; + for (const [id, data] of results) { + map[id] = data?.proxy ? data : null; + } + setConnProxyMap(map); + } catch { + // ignore + } + }, []); + useEffect(() => { if (loading || isSearchProvider) return; fetchProviderModelMeta(); }, [loading, isSearchProvider, fetchProviderModelMeta]); + // Load per-connection effective proxy (handles registry assignments) + useEffect(() => { + if (!loading && connections.length > 0) { + void loadConnProxies(connections); + } + }, [loading, connections, loadConnProxies]); + // Auto-open Add Connection modal when no connections exist (better UX) // Only fires once on initial load, not on HMR remounts or after user dismissal useEffect(() => { @@ -1930,68 +1963,153 @@ export default function ProviderDetailPage() { )} ) : ( -
- {connections - .sort((a, b) => (a.priority || 0) - (b.priority || 0)) - .map((conn, index) => ( - handleSwapPriority(conn, connections[index - 1])} - onMoveDown={() => handleSwapPriority(conn, connections[index + 1])} - onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} - onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} - isCodex={providerId === "codex"} - onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)} - onToggleCodexWeekly={(enabled) => - handleToggleCodexLimit(conn.id, "useWeekly", enabled) - } - onRetest={() => handleRetestConnection(conn.id)} - isRetesting={retestingId === conn.id} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} - onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} - isRefreshing={refreshingId === conn.id} - onProxy={() => - setProxyTarget({ - level: "key", - id: conn.id, - label: conn.name || conn.email || conn.id, - }) - } - hasProxy={ - !!( - proxyConfig?.keys?.[conn.id] || - proxyConfig?.providers?.[providerId] || - proxyConfig?.global - ) - } - proxySource={ - proxyConfig?.keys?.[conn.id] - ? "key" - : proxyConfig?.providers?.[providerId] - ? "provider" - : proxyConfig?.global - ? "global" - : null - } - proxyHost={ - ( - proxyConfig?.keys?.[conn.id] || - proxyConfig?.providers?.[providerId] || - proxyConfig?.global - )?.host || null - } - /> - ))} -
+ (() => { + // Group connections by tag (providerSpecificData.tag) + const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); + const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined); + + if (!hasAnyTag) { + // No tags — render flat list as before + return ( +
+ {sorted.map((conn, index) => ( + handleSwapPriority(conn, sorted[index - 1])} + onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} + onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + isCodex={providerId === "codex"} + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} + onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} + isRefreshing={refreshingId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: conn.name || conn.email || conn.id, + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + /> + ))} +
+ ); + } + + // Build ordered tag groups: untagged first, then alphabetically + const groupMap = new Map(); + for (const conn of sorted) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (!groupMap.has(tag)) groupMap.set(tag, []); + groupMap.get(tag)!.push(conn); + } + const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a.localeCompare(b); + }); + + return ( +
+ {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + {groupConns.length} + +
+ )} +
+ {groupConns.map((conn, index) => ( + + handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) + } + onMoveDown={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) + } + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + isCodex={providerId === "codex"} + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} + onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} + isRefreshing={refreshingId === conn.id} + onProxy={() => + setProxyTarget({ + level: "key", + id: conn.id, + label: conn.name || conn.email || conn.id, + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + /> + ))} +
+
+ ); + })} +
+ ); + })() )} @@ -2188,6 +2306,7 @@ export default function ProviderDetailPage() { level={proxyTarget.level} levelId={proxyTarget.id} levelLabel={proxyTarget.label} + onSaved={() => void loadConnProxies(connections)} /> )} {/* Import Progress Modal */} @@ -4130,6 +4249,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec baseUrl: "", region: "", validationModelId: "", + tag: "", }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -4159,6 +4279,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""), region: existingRegion || (isVertex ? defaultRegion : ""), validationModelId: (connection.providerSpecificData?.validationModelId as string) || "", + tag: (connection.providerSpecificData?.tag as string) || "", }); // Load existing extra keys from providerSpecificData const existing = connection.providerSpecificData?.extraApiKeys; @@ -4282,6 +4403,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec updates.providerSpecificData = { ...(connection.providerSpecificData || {}), extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0), + tag: formData.tag.trim() || undefined, }; if (formData.validationModelId) { updates.providerSpecificData.validationModelId = formData.validationModelId; @@ -4292,6 +4414,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec } else if (isVertex) { updates.providerSpecificData.region = formData.region; } + } else { + // Also persist tag for OAuth accounts + updates.providerSpecificData = { + ...(connection.providerSpecificData || {}), + tag: formData.tag.trim() || undefined, + }; } const error = (await onSave(updates)) as void | unknown; if (error) { @@ -4322,6 +4450,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder={isOAuth ? t("accountName") : t("productionKey")} /> + setFormData({ ...formData, tag: e.target.value })} + placeholder="e.g. personal, work, team-a" + hint="Used to group accounts in the provider view" + /> {isOAuth && connection.email && (

{t("email")}

diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index b402927006..2649d4ece1 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -27,6 +27,14 @@ type HealthInfo = { lastSeenAt: string | null; }; +type TestResult = { + success: boolean; + publicIp?: string; + latencyMs?: number; + country?: string; + error?: string; +}; + const EMPTY_FORM = { id: "", name: "", @@ -51,6 +59,8 @@ export default function ProxyRegistryManager() { const [usageById, setUsageById] = useState>({}); const [healthById, setHealthById] = useState>({}); + const [testById, setTestById] = useState>({}); + const [testingId, setTestingId] = useState(null); const [migrating, setMigrating] = useState(false); const [bulkOpen, setBulkOpen] = useState(false); const [bulkSaving, setBulkSaving] = useState(false); @@ -75,6 +85,36 @@ export default function ProxyRegistryManager() { } }, []); + const loadAllUsage = useCallback(async (proxyIds: string[]) => { + if (!proxyIds.length) return; + try { + const results = await Promise.all( + proxyIds.map((id) => + fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const rawAssignments: Array<{ scope: string; scopeId: string | null }> = + Array.isArray(data?.items) ? data.items : []; + // Deduplicate by scope+scopeId — prevents double-counting when both + // a provider-scope and account-scope row exist for the same proxy + const seen = new Set(); + const assignments = rawAssignments.filter((a) => { + const key = `${a.scope}:${a.scopeId ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return [id, { count: assignments.length, assignments }] as [string, UsageInfo]; + }) + .catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo]) + ) + ); + setUsageById(Object.fromEntries(results)); + } catch { + // ignore + } + }, []); + const load = useCallback(async () => { setLoading(true); setError(null); @@ -86,15 +126,18 @@ export default function ProxyRegistryManager() { setItems([]); return; } - setItems(Array.isArray(data?.items) ? data.items : []); + const loaded: ProxyItem[] = Array.isArray(data?.items) ? data.items : []; + setItems(loaded); + const ids = loaded.map((p) => p.id).filter(Boolean); void loadHealth(); + void loadAllUsage(ids); } catch (e: any) { setError(e?.message || "Failed to load proxy registry"); setItems([]); } finally { setLoading(false); } - }, [loadHealth]); + }, [loadHealth, loadAllUsage]); useEffect(() => { void load(); @@ -130,22 +173,63 @@ export default function ProxyRegistryManager() { const loadUsage = async (proxyId: string) => { try { const res = await fetch( - `/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1` + `/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}` ); const data = await res.json().catch(() => ({})); if (!res.ok) return; + const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray( + data?.items + ) + ? data.items + : []; + const seen = new Set(); + const assignments = rawAssignments.filter((a) => { + const key = `${a.scope}:${a.scopeId ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); setUsageById((prev) => ({ ...prev, - [proxyId]: { - count: Number(data?.count || 0), - assignments: Array.isArray(data?.assignments) ? data.assignments : [], - }, + [proxyId]: { count: assignments.length, assignments }, })); } catch { // ignore usage loading errors in UI } }; + const handleTestProxy = async (item: ProxyItem) => { + if (testingId) return; + setTestingId(item.id); + setTestById((prev) => ({ ...prev, [item.id]: null })); + try { + const res = await fetch("/api/settings/proxy/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + proxy: { + type: item.type || "http", + host: item.host, + port: String(item.port || 8080), + }, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setTestById((prev) => ({ + ...prev, + [item.id]: { success: false, error: data?.error?.message || "Test failed" }, + })); + return; + } + setTestById((prev) => ({ ...prev, [item.id]: { success: true, ...data } })); + } catch (e: any) { + setTestById((prev) => ({ ...prev, [item.id]: { success: false, error: e?.message } })); + } finally { + setTestingId(null); + } + }; + const handleSave = async () => { if (!form.name.trim() || !form.host.trim()) { setError("Name and host are required"); @@ -378,27 +462,47 @@ export default function ProxyRegistryManager() { - {health ? ( -
- {health.successRate ?? 0}% success - {health.avgLatencyMs ?? "-"} ms avg -
- ) : ( - "-" - )} +
+ {health ? ( + <> + {health.successRate ?? 0}% success + {health.avgLatencyMs ?? "-"} ms avg + + ) : testById[item.id] ? ( + testById[item.id]!.success ? ( + <> + + ✓ {testById[item.id]!.publicIp} + + {testById[item.id]!.latencyMs && ( + {testById[item.id]!.latencyMs}ms + )} + + ) : ( + + {testById[item.id]!.error || "failed"} + + ) + ) : ( + + )} +
- {usage ? `${usage.count} assignment(s)` : "-"} + {usageById[item.id] != null + ? `${usageById[item.id].count} assignment(s)` + : "—"}