Add Cloudflare AI provider support and enhance connection management

- Introduced Cloudflare AI as a new provider with specific configurations in providerModels.js and providers.js.
- Updated DefaultExecutor to handle account ID resolution for Cloudflare AI connections.
- Enhanced AddApiKeyModal and EditConnectionModal to include account ID input for Cloudflare AI.
- Implemented validation for Cloudflare AI API key connections in testUtils.js and route.js.
- Updated UI components to reflect changes in provider management and connection handling.
This commit is contained in:
decolua 2026-04-28 11:07:39 +07:00
parent 111e78940a
commit 1bb621317d
18 changed files with 325 additions and 71 deletions

View file

@ -20,6 +20,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
deployment: "",
organization: "",
});
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [validating, setValidating] = useState(false);
@ -42,6 +43,9 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
organization: connection.providerSpecificData.organization || "",
});
}
if (connection.provider === "cloudflare-ai" && connection.providerSpecificData) {
setCloudflareData({ accountId: connection.providerSpecificData.accountId || "" });
}
setTestResult(null);
setValidationResult(null);
}
@ -49,6 +53,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
const isOAuth = connection?.authType === "oauth";
const isAzure = connection?.provider === "azure";
const isCloudflareAi = connection?.provider === "cloudflare-ai";
const isCompatible = connection
? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider))
: false;
@ -80,6 +85,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
provider: connection.provider,
apiKey: formData.apiKey,
...(isAzure ? { providerSpecificData: azureData } : {}),
...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}),
}),
});
const data = await res.json();
@ -113,6 +119,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
provider: connection.provider,
apiKey: formData.apiKey,
...(isAzure ? { providerSpecificData: azureData } : {}),
...(isCloudflareAi ? { providerSpecificData: cloudflareData } : {}),
}),
});
const data = await res.json();
@ -140,6 +147,9 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
organization: azureData.organization,
};
}
if (isCloudflareAi) {
updates.providerSpecificData = { accountId: cloudflareData.accountId };
}
await onSave(updates);
} finally {
@ -197,6 +207,19 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
</>
)}
{isCloudflareAi && (
<div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20">
<h3 className="font-semibold mb-3 text-sm">Cloudflare Workers AI</h3>
<Input
label="Account ID"
value={cloudflareData.accountId}
onChange={(e) => setCloudflareData({ ...cloudflareData, accountId: e.target.value })}
placeholder="abc123def456..."
hint="Find in right sidebar of dash.cloudflare.com"
/>
</div>
)}
{isAzure && (
<div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20">
<h3 className="font-semibold mb-3 text-sm">Azure OpenAI Configuration</h3>
@ -233,7 +256,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
</div>
)}
{!isCompatible && !isAzure && (
{!isCompatible && !isAzure && !isCloudflareAi && (
<div className="flex items-center gap-3">
<Button onClick={handleTest} variant="secondary" disabled={testing}>
{testing ? "Testing..." : "Test Connection"}

View file

@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import PropTypes from "prop-types";
import Card from "./Card";
import Select from "./Select";
import Badge from "./Badge";
const NONE_PROXY_POOL_VALUE = "__none__";
export default function NoAuthProxyCard({ providerId }) {
const [proxyPools, setProxyPools] = useState([]);
const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE);
const [saving, setSaving] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
useEffect(() => {
let cancelled = false;
Promise.all([
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }).then((r) => r.ok ? r.json() : { proxyPools: [] }),
fetch("/api/settings", { cache: "no-store" }).then((r) => r.ok ? r.json() : {}),
]).then(([poolData, settingsData]) => {
if (cancelled) return;
setProxyPools(poolData.proxyPools || []);
const override = (settingsData.providerStrategies || {})[providerId] || {};
setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE);
}).catch(() => {});
return () => { cancelled = true; };
}, [providerId]);
const handleChange = async (newValue) => {
setProxyPoolId(newValue);
setSaving(true);
try {
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};
const current = data.providerStrategies || {};
const override = { ...(current[providerId] || {}) };
if (newValue === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
else override.proxyPoolId = newValue;
const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerId];
else updated[providerId] = override;
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerStrategies: updated }),
});
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1500);
} catch (e) {
console.log("Save proxyPoolId error:", e);
} finally {
setSaving(false);
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">lock_open</span>
</div>
<div className="flex-1">
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.</p>
</div>
{savedFlash && <Badge variant="success" size="sm">Saved</Badge>}
</div>
<Select
label="Proxy Pool"
value={proxyPoolId}
onChange={(e) => handleChange(e.target.value)}
disabled={saving}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None (direct)" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
]}
/>
</Card>
);
}
NoAuthProxyCard.propTypes = {
providerId: PropTypes.string.isRequired,
};

View file

@ -0,0 +1,62 @@
"use client";
import Card from "./Card";
// Field schema — config-driven, used for both searchConfig and fetchConfig
const FIELD_SCHEMA = {
baseUrl: { label: "Endpoint", format: (v) => v, isLink: true, mono: true },
method: { label: "Method", format: (v) => v },
authType: { label: "Auth", format: (v) => v },
authHeader: { label: "Auth Header", format: (v) => v, mono: true },
costPerQuery: { label: "Cost / call", format: (v) => v === 0 ? "Free" : `$${v.toFixed(4)}` },
freeMonthlyQuota: { label: "Free quota", format: (v) => v === 0 ? "—" : v >= 999999 ? "Unlimited" : `${v.toLocaleString()} / mo` },
searchTypes: { label: "Types", format: (v) => v.join(", ") },
formats: { label: "Formats", format: (v) => v.join(", ") },
defaultMaxResults: { label: "Default results", format: (v) => v },
maxMaxResults: { label: "Max results", format: (v) => v },
maxCharacters: { label: "Max chars", format: (v) => v.toLocaleString() },
timeoutMs: { label: "Timeout", format: (v) => `${v / 1000}s` },
cacheTTLMs: { label: "Cache TTL", format: (v) => `${v / 60000}m` },
};
export default function ProviderInfoCard({ config, title = "Provider Info" }) {
if (!config) return null;
const rows = Object.entries(FIELD_SCHEMA)
.filter(([key]) => config[key] !== undefined && config[key] !== null && config[key] !== "")
.map(([key, schema]) => ({
key,
label: schema.label,
value: schema.format(config[key]),
isLink: schema.isLink,
mono: schema.mono,
raw: config[key],
}));
return (
<Card>
<h2 className="text-lg font-semibold mb-3">{title}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
{rows.map((r) => (
<div key={r.key} className="flex items-center gap-3 min-w-0">
<span className="text-xs text-text-muted w-28 shrink-0">{r.label}</span>
{r.isLink ? (
<a
href={r.raw}
target="_blank"
rel="noopener noreferrer"
className={`text-sm text-primary hover:underline truncate ${r.mono ? "font-mono" : ""}`}
>
{r.value}
</a>
) : (
<span className={`text-sm text-text-main truncate ${r.mono ? "font-mono" : ""}`}>
{r.value}
</span>
)}
</div>
))}
</div>
</Card>
);
}

View file

@ -12,7 +12,7 @@ import Button from "./Button";
import { ConfirmModal } from "./Modal";
// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts"];
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "webSearch", "webFetch"];
const navItems = [
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },

View file

@ -30,8 +30,10 @@ export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as EditConnectionModal } from "./EditConnectionModal";
export { default as AddCustomEmbeddingModal } from "./AddCustomEmbeddingModal";
export { default as NoAuthProxyCard } from "./NoAuthProxyCard";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";
export { default as ProviderInfoCard } from "./ProviderInfoCard";
// Layouts
export * from "./layouts";