fix: proxy UI bugs, connection tag grouping, and function_call prefix stripping

## Proxy UI Bug Fixes
- fix: proxy badge on connection cards now uses resolveProxyForConnection()
  per-connection (covers registry + config-file assignments)
- fix: Test Connection button now works in 'saved' proxy mode by resolving
  proxy config from savedProxies list
- fix: ProxyConfigModal now calls onClose() after save/clear (fixes UI freeze)
- fix: ProxyRegistryManager loads usage eagerly on mount with deduplication
  by scope+scopeId to prevent double-counting; adds per-row Test button

## Connection Tag Grouping (new feature)
- feat: add Tag/Group field to EditConnectionModal (stored in
  providerSpecificData.tag, no DB schema change)
- feat: connections list groups by tag with visual dividers when any account
  has a tag; untagged accounts appear first without header

## Post-merge fix from PR #607 review
- fix: function_call blocks in translateNonStreamingResponse now also strip
  Claude OAuth proxy_ prefix via toolNameMap (kilo-code-bot #607 warning)
  Affects OpenAI Responses API format path — tool_use was fixed in PR #607
  but function_call was missed
This commit is contained in:
diegosouzapw
2026-03-25 08:54:46 -03:00
parent aa2a7d12be
commit 5907296d36
4 changed files with 367 additions and 96 deletions

View File

@@ -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,
},
});

View File

@@ -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<string, { proxy: any; level: string } | null>
>({});
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<string, { proxy: any; level: string } | null> = {};
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() {
)}
</div>
) : (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => 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
}
/>
))}
</div>
(() => {
// 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 (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{sorted.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === sorted.length - 1}
onMoveUp={() => 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}
/>
))}
</div>
);
}
// Build ordered tag groups: untagged first, then alphabetically
const groupMap = new Map<string, typeof sorted>();
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 (
<div className="flex flex-col gap-0">
{groupKeys.map((tag, gi) => {
const groupConns = groupMap.get(tag)!;
return (
<div
key={tag || "__untagged__"}
className={
gi > 0
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
: ""
}
>
{tag && (
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
label
</span>
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
{tag}
</span>
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
<span className="text-[10px] text-text-muted/40">
{groupConns.length}
</span>
</div>
)}
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{groupConns.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={gi === 0 && index === 0}
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
onMoveUp={() =>
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}
/>
))}
</div>
</div>
);
})}
</div>
);
})()
)}
</Card>
@@ -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")}
/>
<Input
label="Tag / Group"
value={formData.tag}
onChange={(e) => 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 && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>

View File

@@ -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<Record<string, UsageInfo>>({});
const [healthById, setHealthById] = useState<Record<string, HealthInfo>>({});
const [testById, setTestById] = useState<Record<string, TestResult | null>>({});
const [testingId, setTestingId] = useState<string | null>(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<string>();
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<string>();
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() {
</span>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{health ? (
<div className="flex flex-col gap-0.5">
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</div>
) : (
"-"
)}
<div className="flex flex-col gap-0.5">
{health ? (
<>
<span>{health.successRate ?? 0}% success</span>
<span>{health.avgLatencyMs ?? "-"} ms avg</span>
</>
) : testById[item.id] ? (
testById[item.id]!.success ? (
<>
<span className="text-emerald-400">
{testById[item.id]!.publicIp}
</span>
{testById[item.id]!.latencyMs && (
<span>{testById[item.id]!.latencyMs}ms</span>
)}
</>
) : (
<span className="text-red-400">
{testById[item.id]!.error || "failed"}
</span>
)
) : (
<span></span>
)}
</div>
</td>
<td className="py-2 pr-3 text-xs text-text-muted">
{usage ? `${usage.count} assignment(s)` : "-"}
{usageById[item.id] != null
? `${usageById[item.id].count} assignment(s)`
: "—"}
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
icon="visibility"
onClick={() => void loadUsage(item.id)}
icon="speed"
onClick={() => void handleTestProxy(item)}
loading={testingId === item.id}
>
Usage
Test
</Button>
<Button
size="sm"

View File

@@ -245,6 +245,7 @@ export default function ProxyConfigModal({
setSelectedProxyId("");
}
onSaved?.();
onClose();
} catch (error) {
console.error("Error saving proxy:", error);
setFormError(error.message || "Failed to save proxy configuration");
@@ -281,6 +282,7 @@ export default function ProxyConfigModal({
setSelectedProxyId("");
setTestResult(null);
onSaved?.();
onClose();
} catch (error) {
console.error("Error clearing proxy:", error);
setFormError(error.message || "Failed to clear proxy configuration");
@@ -290,22 +292,49 @@ export default function ProxyConfigModal({
};
const handleTest = async () => {
if (mode === "saved") {
setFormError("Use custom mode to run manual connection test.");
return;
}
if (!host.trim()) return;
setFormError(null);
setTesting(true);
setTestResult(null);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
let proxy: {
type: string;
host: string;
port: string;
username?: string;
password?: string;
} | null = null;
if (mode === "saved") {
if (!selectedProxyId) {
setFormError("Select a saved proxy first.");
setTesting(false);
return;
}
const found = (savedProxies as any[]).find((p: any) => p.id === selectedProxyId);
if (!found) {
setFormError("Selected proxy not found.");
setTesting(false);
return;
}
proxy = {
type: found.type || "http",
host: found.host || "",
port: String(found.port || 8080),
};
} else {
if (!host.trim()) {
setTesting(false);
return;
}
proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
}
const res = await fetch("/api/settings/proxy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -550,7 +579,7 @@ export default function ProxyConfigModal({
icon="speed"
onClick={handleTest}
loading={testing}
disabled={mode !== "custom" || !host.trim()}
disabled={mode === "saved" ? !selectedProxyId : !host.trim()}
>
Test Connection
</Button>