Fix private AI custom endpoints (#1299)

This commit is contained in:
ZacharyZcR
2026-08-23 22:38:03 +08:00
committed by GitHub
parent fbf267fe5f
commit b5d13c3664
5 changed files with 50 additions and 10 deletions
+10 -5
View File
@@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string {
/**
* True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else resolves through DNS and
* is caught at connect time by the guard instead.
* private only if it is "localhost" -- anything else needs DNS resolution.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
@@ -98,12 +97,18 @@ export function evaluateEgress(
const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false };
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
return {
allowed: false,
isPrivate: true,
+10
View File
@@ -61,6 +61,16 @@ describe("evaluateEgress", () => {
expect(decision.isPrivate).toBe(true);
});
it.each(["llm.internal", "host.docker.internal"])(
"routes allowlisted private DNS name %s through the private path",
(host) => {
expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({
allowed: true,
isPrivate: true,
});
},
);
it("matches the allowlist case-insensitively", () => {
expect(
evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed,
+5 -1
View File
@@ -114,7 +114,11 @@ export async function probeAiModels(input: {
baseUrl?: string | null;
apiKey?: string | null;
providerId?: number | null;
}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> {
}): Promise<{
models: string[];
source: "live" | "fallback" | "none";
warning?: string;
}> {
try {
return (await authApi.post("/ai/probe-models", input)).data;
} catch (error) {
+12 -4
View File
@@ -85,21 +85,24 @@ function AiProviderEditForm({
const [models, setModels] = useState<string[]>([]);
const [customModel, setCustomModel] = useState(false);
const [detecting, setDetecting] = useState(false);
const [detectWarning, setDetectWarning] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const detectModels = useCallback(async () => {
setDetecting(true);
setDetectWarning(null);
try {
const detected = await getAiProviderModels(provider.id);
setModels(detected);
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
} catch {
} catch (error) {
setModels([]);
setCustomModel(true);
setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed")));
} finally {
setDetecting(false);
}
}, [provider.id, defaultModel]);
}, [provider.id, defaultModel, t]);
useEffect(() => {
void detectModels();
@@ -203,6 +206,11 @@ function AiProviderEditForm({
placeholder={t("ai.defaultModelPlaceholder")}
/>
)}
{detectWarning && (
<p className="text-[11px] leading-snug text-destructive">
{detectWarning}
</p>
)}
</div>
<div className="flex gap-2">
@@ -269,8 +277,8 @@ export function AiProviderSettings({
apiKey: apiKey.trim() || null,
});
setModels(result.models);
if (result.source === "fallback") {
setDetectWarning(t("ai.modelDetectFailed"));
if (result.source !== "live") {
setDetectWarning(result.warning || t("ai.modelDetectFailed"));
}
// Pick the first suggestion so the field is never left empty.
setDefaultModel((current) => current || result.models[0] || "");
@@ -82,4 +82,17 @@ describe("AiProviderSettings", () => {
screen.getByRole("combobox", { name: "ai.defaultModel" }),
).toBeTruthy();
});
it("shows the provider error when refreshing models fails", async () => {
api.getAiProviderModels.mockRejectedValue(
new Error("Add llm.internal to the AI endpoint allowlist"),
);
render(<AiProviderSettings providers={[provider]} onChanged={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" }));
expect(
await screen.findByText("Add llm.internal to the AI endpoint allowlist"),
).toBeTruthy();
});
});