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:
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(/\/$/, "");
|
||||
|
||||
@@ -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(/\/$/, "");
|
||||
|
||||
Reference in New Issue
Block a user