mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 647cb46fe3 into bf1bd16926
This commit is contained in:
commit
ee577cad3d
6 changed files with 169 additions and 72 deletions
|
|
@ -189,6 +189,34 @@ describe("AgentCardDiscovery", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("sends the credentials only when the admin clicks Discover", async () => {
|
||||
mockDiscover.mockRejectedValueOnce(new Error("HTTP 401")).mockResolvedValue({
|
||||
url: "https://lg.example.com",
|
||||
agent_card: sampleCard,
|
||||
});
|
||||
const plan = {
|
||||
url: "https://lg.example.com",
|
||||
discovery_mode: "langgraph_platform" as const,
|
||||
params: { assistant_id: "asst_1" },
|
||||
api_key: "sk-1",
|
||||
static_headers: { "x-tenant": "t1" },
|
||||
};
|
||||
renderWithProviders(<AgentCardDiscovery accessToken="tok" onApply={vi.fn()} discoveryRequest={plan} />);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(await screen.findByText(/HTTP 401/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Discover/ }));
|
||||
|
||||
await waitFor(() => expect(mockDiscover).toHaveBeenCalledTimes(2));
|
||||
const anonymous = { discovery_mode: "langgraph_platform", params: { assistant_id: "asst_1" } };
|
||||
expect(mockDiscover).toHaveBeenNthCalledWith(1, "tok", "https://lg.example.com", anonymous);
|
||||
expect(mockDiscover).toHaveBeenNthCalledWith(2, "tok", "https://lg.example.com", {
|
||||
...anonymous,
|
||||
api_key: "sk-1",
|
||||
static_headers: { "x-tenant": "t1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Re-discover until the parent provides a usable URL", async () => {
|
||||
renderWithProviders(
|
||||
<AgentCardDiscovery
|
||||
|
|
|
|||
|
|
@ -118,65 +118,73 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
[discoveryRequest?.params],
|
||||
);
|
||||
|
||||
const handleDiscover = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
setError(i18n.t("agents.cardDiscovery.errors.noAccessToken"));
|
||||
onApplyRef.current(null);
|
||||
return;
|
||||
}
|
||||
const trimmed = effectiveUrl.trim();
|
||||
if (!trimmed) {
|
||||
setError(
|
||||
isParentDriven
|
||||
? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails")
|
||||
: i18n.t("agents.cardDiscovery.errors.enterBaseUrl"),
|
||||
);
|
||||
setCard(null);
|
||||
onApplyRef.current(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDiscoveryRequest = discoveryRequestRef.current;
|
||||
const requestId = ++discoverRequestIdRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await discoverAgentCardCall(
|
||||
accessToken,
|
||||
trimmed,
|
||||
isParentDriven && currentDiscoveryRequest
|
||||
? {
|
||||
discovery_mode: currentDiscoveryRequest.discovery_mode,
|
||||
params: currentDiscoveryRequest.params,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (requestId !== discoverRequestIdRef.current) return;
|
||||
lastSyncedSelectionRef.current = null;
|
||||
setCard(response.agent_card);
|
||||
resetSelections(response.agent_card);
|
||||
} catch (e: any) {
|
||||
if (requestId !== discoverRequestIdRef.current) return;
|
||||
setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed"));
|
||||
setCard(null);
|
||||
lastSyncedSelectionRef.current = null;
|
||||
onApplyRef.current(null);
|
||||
} finally {
|
||||
if (requestId === discoverRequestIdRef.current) {
|
||||
setLoading(false);
|
||||
// Credentials go only with an explicit Discover, never to a URL the admin is still typing.
|
||||
const handleDiscover = useCallback(
|
||||
async (withCredentials?: boolean) => {
|
||||
if (!accessToken) {
|
||||
setError(i18n.t("agents.cardDiscovery.errors.noAccessToken"));
|
||||
onApplyRef.current(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for
|
||||
// ``discoveryRequest`` content; the actual object is read via the ref
|
||||
// above so identity churn from the parent doesn't recreate this callback.
|
||||
// ``savedAgentCard`` is intentionally NOT a dep — it's read via
|
||||
// ``savedAgentCardRef`` inside ``resetSelections``. Including it here
|
||||
// would recreate this callback whenever the parent hands us a new
|
||||
// ``savedAgentCard`` object (e.g. a background refresh of agent data
|
||||
// during editing), which would re-fire the auto-discover effect and
|
||||
// wipe in-progress user selections.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey]);
|
||||
const trimmed = effectiveUrl.trim();
|
||||
if (!trimmed) {
|
||||
setError(
|
||||
isParentDriven
|
||||
? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails")
|
||||
: i18n.t("agents.cardDiscovery.errors.enterBaseUrl"),
|
||||
);
|
||||
setCard(null);
|
||||
onApplyRef.current(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDiscoveryRequest = discoveryRequestRef.current;
|
||||
const requestId = ++discoverRequestIdRef.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await discoverAgentCardCall(
|
||||
accessToken,
|
||||
trimmed,
|
||||
isParentDriven && currentDiscoveryRequest
|
||||
? {
|
||||
discovery_mode: currentDiscoveryRequest.discovery_mode,
|
||||
params: currentDiscoveryRequest.params,
|
||||
...(withCredentials && {
|
||||
api_key: currentDiscoveryRequest.api_key,
|
||||
static_headers: currentDiscoveryRequest.static_headers,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (requestId !== discoverRequestIdRef.current) return;
|
||||
lastSyncedSelectionRef.current = null;
|
||||
setCard(response.agent_card);
|
||||
resetSelections(response.agent_card);
|
||||
} catch (e: any) {
|
||||
if (requestId !== discoverRequestIdRef.current) return;
|
||||
setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed"));
|
||||
setCard(null);
|
||||
lastSyncedSelectionRef.current = null;
|
||||
onApplyRef.current(null);
|
||||
} finally {
|
||||
if (requestId === discoverRequestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
// ``discoveryMode`` / ``discoveryParamsKey`` are primitive proxies for
|
||||
// ``discoveryRequest`` content; the actual object is read via the ref
|
||||
// above so identity churn from the parent doesn't recreate this callback.
|
||||
// ``savedAgentCard`` is intentionally NOT a dep — it's read via
|
||||
// ``savedAgentCardRef`` inside ``resetSelections``. Including it here
|
||||
// would recreate this callback whenever the parent hands us a new
|
||||
// ``savedAgentCard`` object (e.g. a background refresh of agent data
|
||||
// during editing), which would re-fire the auto-discover effect and
|
||||
// wipe in-progress user selections.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[accessToken, effectiveUrl, isParentDriven, discoveryMode, discoveryParamsKey],
|
||||
);
|
||||
|
||||
const debouncedDiscover = useDebouncedCallback(
|
||||
() => {
|
||||
|
|
@ -283,7 +291,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleDiscover} disabled={loading || !effectiveUrl.trim()}>
|
||||
<Button onClick={() => handleDiscover(true)} disabled={loading || !effectiveUrl.trim()}>
|
||||
{renderDiscoverIcon()}
|
||||
{discoverLabel}
|
||||
</Button>
|
||||
|
|
@ -312,7 +320,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button onClick={handleDiscover} disabled={loading}>
|
||||
<Button onClick={() => handleDiscover()} disabled={loading}>
|
||||
{renderDiscoverIcon()}
|
||||
{discoverLabel}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -303,16 +303,9 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
|
|||
if (values.rpm_limit != null) agentData.rpm_limit = values.rpm_limit;
|
||||
if (values.session_tpm_limit != null) agentData.session_tpm_limit = values.session_tpm_limit;
|
||||
if (values.session_rpm_limit != null) agentData.session_rpm_limit = values.session_rpm_limit;
|
||||
// static_headers: convert [{header, value}, ...] → {header: value, ...}
|
||||
if (Array.isArray(values.static_headers) && values.static_headers.length > 0) {
|
||||
const staticHeaders: Record<string, string> = {};
|
||||
values.static_headers.forEach((entry: { header?: string; value?: string }) => {
|
||||
const key = entry?.header?.trim();
|
||||
if (key) staticHeaders[key] = entry?.value ?? "";
|
||||
});
|
||||
if (Object.keys(staticHeaders).length > 0) {
|
||||
agentData.static_headers = staticHeaders;
|
||||
}
|
||||
const staticHeaders = staticHeadersFromForm(values.static_headers);
|
||||
if (Object.keys(staticHeaders).length > 0) {
|
||||
agentData.static_headers = staticHeaders;
|
||||
}
|
||||
|
||||
// extra_headers: already an array of strings from Select tags
|
||||
|
|
@ -325,6 +318,18 @@ export const buildAgentDataFromForm = (values: any, existingAgent?: any) => {
|
|||
return agentData;
|
||||
};
|
||||
|
||||
/** Converts the form's [{header, value}, ...] rows to {header: value, ...}, skipping rows without a name. */
|
||||
export const staticHeadersFromForm = (rows: unknown): Record<string, string> => {
|
||||
const staticHeaders: Record<string, string> = {};
|
||||
if (Array.isArray(rows)) {
|
||||
rows.forEach((entry: { header?: string; value?: string }) => {
|
||||
const key = entry?.header?.trim();
|
||||
if (key) staticHeaders[key] = entry?.value ?? "";
|
||||
});
|
||||
}
|
||||
return staticHeaders;
|
||||
};
|
||||
|
||||
export const applyKillSwitchToPayload = (
|
||||
agentData: { kill_switch?: KillSwitchConfig | null },
|
||||
form: KillSwitchFormValue | undefined,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { selectionsFromSavedAgentCard, selectionsFromUpstreamCard, skillId } from "./agent_discovery_utils";
|
||||
import {
|
||||
buildDiscoveryRequest,
|
||||
selectionsFromSavedAgentCard,
|
||||
selectionsFromUpstreamCard,
|
||||
skillId,
|
||||
} from "./agent_discovery_utils";
|
||||
|
||||
const upstreamCard = {
|
||||
name: "Upstream Agent",
|
||||
|
|
@ -50,3 +55,44 @@ describe("selectionsFromUpstreamCard", () => {
|
|||
expect(result.editedName).toBe("Upstream Agent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDiscoveryRequest", () => {
|
||||
it("sends the LangGraph API key typed into the form", () => {
|
||||
const values = { api_base: "https://lg.example.com/", assistant_id: "asst_1", api_key: "sk-1" };
|
||||
|
||||
const plan = {
|
||||
url: "https://lg.example.com",
|
||||
discovery_mode: "langgraph_platform",
|
||||
params: { assistant_id: "asst_1" },
|
||||
display_url: "https://lg.example.com/.well-known/agent-card.json?assistant_id=asst_1",
|
||||
api_key: "sk-1",
|
||||
};
|
||||
|
||||
expect(buildDiscoveryRequest("langgraph", values)).toEqual(plan);
|
||||
});
|
||||
|
||||
it("sends no key when the edit form holds the redacted saved one", () => {
|
||||
const values = { api_base: "https://lg.example.com", assistant_id: "asst_1", api_key: "REDACTED_BY_LITELM" };
|
||||
|
||||
expect(buildDiscoveryRequest("langgraph", values)).not.toHaveProperty("api_key");
|
||||
});
|
||||
|
||||
it("sends an A2A agent's named static headers", () => {
|
||||
const values = {
|
||||
url: "https://agent.example.com",
|
||||
static_headers: [
|
||||
{ header: " Authorization ", value: "Basic abc" },
|
||||
{ header: " ", value: "orphan" },
|
||||
],
|
||||
};
|
||||
|
||||
const plan = {
|
||||
url: "https://agent.example.com",
|
||||
discovery_mode: "well_known_fallback",
|
||||
display_url: "https://agent.example.com/.well-known/agent-card.json",
|
||||
static_headers: { Authorization: "Basic abc" },
|
||||
};
|
||||
|
||||
expect(buildDiscoveryRequest("a2a", values)).toEqual(plan);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { AgentCreateInfo, DiscoveredAgentCard, DiscoveryMode } from "@/components/networking";
|
||||
import { staticHeadersFromForm } from "./agent_config";
|
||||
|
||||
export interface DiscoveryRequestPlan {
|
||||
url: string;
|
||||
discovery_mode: DiscoveryMode;
|
||||
params?: Record<string, any>;
|
||||
display_url?: string;
|
||||
api_key?: string;
|
||||
static_headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
const REDACTED_SECRET = "REDACTED_BY_LITELM";
|
||||
|
||||
export const skillId = (skill: any, idx: number): string => skill?.id ?? skill?.name ?? `skill-${idx}`;
|
||||
|
||||
export const ALLOWED_CAPABILITY_KEYS = ["streaming"] as const;
|
||||
|
|
@ -137,21 +142,26 @@ export const buildDiscoveryRequest = (
|
|||
const assistantId = trim(values.assistant_id);
|
||||
if (!base || !assistantId) return undefined;
|
||||
const query = `?assistant_id=${encodeURIComponent(assistantId)}`;
|
||||
const apiKey = typeof values.api_key === "string" ? values.api_key : "";
|
||||
return {
|
||||
url: base,
|
||||
discovery_mode: "langgraph_platform",
|
||||
params: { assistant_id: assistantId },
|
||||
display_url: `${base}/.well-known/agent-card.json${query}`,
|
||||
// A saved key reads back redacted, so only a key typed into the form is sent.
|
||||
...(apiKey.trim() && apiKey !== REDACTED_SECRET ? { api_key: apiKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (agentType === "a2a" || selectedAgentTypeInfo?.use_a2a_form_fields) {
|
||||
const base = stripTrailingSlash(trim(values.url));
|
||||
if (!base) return undefined;
|
||||
const staticHeaders = staticHeadersFromForm(values.static_headers);
|
||||
return {
|
||||
url: base,
|
||||
discovery_mode: "well_known_fallback",
|
||||
display_url: `${base}/.well-known/agent-card.json`,
|
||||
...(Object.keys(staticHeaders).length > 0 ? { static_headers: staticHeaders } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4353,7 +4353,7 @@ type DiscoverAgentCardResponse = Omit<components["schemas"]["DiscoverAgentRespon
|
|||
|
||||
export type DiscoveryMode = components["schemas"]["DiscoveryMode"];
|
||||
type DiscoverAgentCardOptions = Partial<
|
||||
Pick<components["schemas"]["DiscoverAgentRequest"], "discovery_mode" | "params">
|
||||
Pick<components["schemas"]["DiscoverAgentRequest"], "discovery_mode" | "params" | "api_key" | "static_headers">
|
||||
>;
|
||||
|
||||
export const discoverAgentCardCall = async (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue