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

@@ -25,7 +25,7 @@ export default function APIPageClient({ machineId }) {
const [requireLogin, setRequireLogin] = useState(true);
const [hasPassword, setHasPassword] = useState(true);
const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false);
const [rtkEnabled, setRtkEnabledState] = useState(false);
const [rtkEnabled, setRtkEnabledState] = useState(true);
// Cloudflare Tunnel state
const [tunnelChecking, setTunnelChecking] = useState(true);
@@ -81,7 +81,7 @@ export default function APIPageClient({ machineId }) {
setRequireLogin(data.requireLogin !== false);
setHasPassword(data.hasPassword || false);
setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
setRtkEnabledState(data.rtkEnabled || false);
setRtkEnabledState(data.rtkEnabled !== false);
}
if (statusRes.ok) {
const data = await statusRes.json();
@@ -816,30 +816,13 @@ export default function APIPageClient({ machineId }) {
{/* Token Saver (RTK) */}
<Card id="rtk">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Token Saver</h2>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
Experimental
</span>
</div>
<h2 className="text-lg font-semibold">Token Saver</h2>
</div>
<div className="flex items-center justify-between pt-2">
<div className="pr-4">
<p className="font-medium">Compress tool output</p>
<p className="text-sm text-text-muted">
Auto-compress git diff / status / grep / find / ls / tree / logs in <code>tool_result</code> before sending to LLM. Check server console for <code>[RTK] saved ...</code> log.
</p>
<p className="text-xs text-text-muted mt-1">
Inspired by{" "}
<a
href="https://github.com/rtk-ai/rtk"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-primary"
>
RTK (Rust Token Killer)
</a>
{" "}— ported to JavaScript. This feature is still under testing; disable it if you notice unexpected results.
Auto-compress tool output (git diff/grep/ls/tree/logs) before sending to LLM to save tokens. Disable if you see issues.
</p>
</div>
<Toggle

View File

@@ -3,7 +3,7 @@
import { useParams, notFound, useRouter } from "next/navigation";
import Link from "next/link";
import { useState, useEffect } from "react";
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
import { Card, Badge, Button, AddCustomEmbeddingModal, NoAuthProxyCard, ProviderInfoCard } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
@@ -49,6 +49,12 @@ const KIND_EXAMPLE_CONFIG = {
defaultInput: "What is the latest news about AI?",
bodyKey: "query",
defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`,
extraFields: [
{ key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] },
{ key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 },
{ key: "country", label: "Country", type: "text", default: "" },
{ key: "language", label: "Language", type: "text", default: "" },
],
},
webFetch: {
inputLabel: "URL",
@@ -56,6 +62,10 @@ const KIND_EXAMPLE_CONFIG = {
defaultInput: "https://example.com",
bodyKey: "url",
defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`,
extraFields: [
{ key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] },
{ key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 },
],
},
image: {
inputLabel: "Prompt",
@@ -916,7 +926,8 @@ function GenericExampleCard({ providerId, kind }) {
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
const apiPath = kindConfig.endpoint.path;
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
// For kinds without model concept (webSearch/webFetch), use providerAlias directly
const modelFull = kindModels.length === 0 ? providerAlias : (selectedModel ? `${providerAlias}/${selectedModel}` : "");
// Build request body with optional extra fields (only non-empty values)
const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
@@ -1160,9 +1171,9 @@ function GenericExampleCard({ providerId, kind }) {
</Row>
)}
{/* Extra fields (filtered by model.params; if undefined → none shown) */}
{/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */}
{(exConfig.extraFields || [])
.filter((f) => Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))
.filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
.map((f) => (
<Row key={f.key} label={f.label}>
{f.type === "select" ? (
@@ -1175,6 +1186,14 @@ function GenericExampleCard({ providerId, kind }) {
<option key={opt} value={opt}>{opt === "" ? "(default)" : opt}</option>
))}
</select>
) : f.type === "text" ? (
<input
type="text"
value={extraValues[f.key] ?? ""}
placeholder={f.placeholder}
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
) : (
<input
type="number"
@@ -1413,23 +1432,13 @@ export default function MediaProviderDetailPage() {
{/* Connections */}
{!isCustom && provider.noAuth ? (
<Card>
<div className="flex items-center gap-3">
<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>
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">This provider is ready to use.</p>
</div>
</div>
</Card>
<NoAuthProxyCard providerId={id} />
) : (
<ConnectionsCard providerId={id} isOAuth={false} />
)}
{/* Models - only for non-tts kinds; custom uses prefix as alias */}
{kind !== "tts" && (
{/* Models - hidden for tts/webSearch/webFetch (provider IS the model); custom uses prefix as alias */}
{kind !== "tts" && kind !== "webSearch" && kind !== "webFetch" && (
<ModelsCard
providerId={id}
kindFilter={kind}
@@ -1437,6 +1446,14 @@ export default function MediaProviderDetailPage() {
/>
)}
{/* Provider Info — config-driven, only for providers with searchConfig/fetchConfig */}
{!isCustom && (provider.searchConfig || provider.fetchConfig) && (
<ProviderInfoCard
config={kind === "webFetch" ? provider.fetchConfig : provider.searchConfig}
title={`${kindConfig.label} Config`}
/>
)}
{/* Example — per kind */}
{kind === "embedding" && (
<EmbeddingExampleCard providerId={id} customAlias={customNode?.prefix} />

View File

@@ -14,6 +14,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
: "";
const isAzure = provider === "azure";
const isCloudflareAi = provider === "cloudflare-ai";
const [formData, setFormData] = useState({
name: "",
@@ -28,6 +29,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
deployment: "",
organization: "",
});
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
@@ -44,6 +46,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
organization: azureData.organization,
};
}
if (isCloudflareAi) {
return { accountId: cloudflareData.accountId };
}
return undefined;
};
@@ -180,6 +185,20 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
}
</p>
)}
{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..."
/>
<p className="text-xs text-text-muted mt-2">
Find your Account ID in the right sidebar of <a href="https://dash.cloudflare.com" target="_blank" rel="noopener noreferrer" className="text-primary underline">dash.cloudflare.com</a>
</p>
</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>
@@ -241,7 +260,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
</p>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={saving || (!isOllamaLocal && (!formData.name || !formData.apiKey)) || (isAzure && (!azureData.azureEndpoint || !azureData.deployment || !azureData.organization))}>
<Button onClick={handleSubmit} fullWidth disabled={saving || (!isOllamaLocal && (!formData.name || !formData.apiKey)) || (isAzure && (!azureData.azureEndpoint || !azureData.deployment || !azureData.organization)) || (isCloudflareAi && !cloudflareData.accountId)}>
{saving ? "Saving..." : "Save"}
</Button>

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal } from "@/shared/components";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -849,17 +849,7 @@ export default function ProviderDetailPage() {
{/* Connections */}
{isFreeNoAuth ? (
<Card>
<div className="flex items-center gap-3">
<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>
<p className="text-sm font-medium">No authentication required</p>
<p className="text-xs text-text-muted">This provider is ready to use.</p>
</div>
</div>
</Card>
<NoAuthProxyCard providerId={providerId} />
) : (
<Card>
<div className="flex items-center justify-between mb-4">

View File

@@ -367,6 +367,19 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
try {
switch (connection.provider) {
case "cloudflare-ai": {
const psd = connection.providerSpecificData || {};
const accountId = psd.accountId;
if (!accountId) return { valid: false, error: "Missing Account ID" };
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1/chat/completions`;
const res = await fetchWithConnectionProxy(url, {
method: "POST",
headers: { "Authorization": `Bearer ${connection.apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: getDefaultModel("cloudflare-ai"), messages: [{ role: "user", content: "test" }], max_tokens: 1 }),
}, effectiveProxy);
const valid = res.status !== 401 && res.status !== 403 && res.status !== 404;
return { valid, error: valid ? null : "Invalid API token or Account ID" };
}
case "azure": {
const psd = connection.providerSpecificData || {};
const endpoint = (psd.azureEndpoint || "").replace(/\/$/, "");

View File

@@ -95,6 +95,29 @@ export async function POST(request) {
});
}
if (provider === "cloudflare-ai") {
const { providerSpecificData } = body;
const accountId = providerSpecificData?.accountId;
if (!accountId) {
return NextResponse.json({ valid: false, error: "Missing Account ID" });
}
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1/chat/completions`;
const cfRes = await fetch(url, {
method: "POST",
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: getDefaultModel("cloudflare-ai"),
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
isValid = cfRes.status !== 401 && cfRes.status !== 403 && cfRes.status !== 404;
return NextResponse.json({
valid: isValid,
error: isValid ? null : "Invalid API token or Account ID",
});
}
if (provider === "azure") {
const { providerSpecificData } = body;
const endpoint = (providerSpecificData?.azureEndpoint || "").replace(/\/$/, "");