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

@@ -344,6 +344,10 @@ export const PROVIDER_MODELS = {
{ id: "GLM-4.7", name: "GLM-4.7" },
{ id: "DeepSeek-V3.2", name: "DeepSeek-V3.2" },
],
"cloudflare-ai": [
{ id: "@cf/moonshotai/kimi-k2.6", name: "Kimi K2.6" },
{ id: "@cf/zai-org/glm-4.7-flash", name: "GLM 4.7 Flash" },
],
byteplus: [
{ id: "seed-2-0-pro-260328", name: "Seed 2.0 Pro" },
{ id: "seed-2-0-code-preview-260328", name: "Seed 2.0 Code Preview" },

View File

@@ -367,6 +367,11 @@ export const PROVIDERS = {
format: "openai",
headers: {}
},
// Cloudflare Workers AI - {accountId} resolved from credentials.providerSpecificData.accountId
"cloudflare-ai": {
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions",
format: "openai"
},
};
export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";

View File

@@ -32,8 +32,15 @@ export class DefaultExecutor extends BaseExecutor {
return `${this.config.baseUrl}?beta=true`;
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default:
return this.config.baseUrl;
default: {
const url = this.config.baseUrl;
if (url?.includes("{accountId}")) {
const accountId = credentials?.providerSpecificData?.accountId;
if (!accountId) throw new Error(`${this.provider} requires accountId in providerSpecificData`);
return url.replace("{accountId}", accountId);
}
return url;
}
}
}

View File

@@ -206,7 +206,7 @@ export async function refreshCodexToken(refreshToken, log) {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.codex.clientId,
scope: "openid profile email",
scope: "openid profile email offline_access",
}),
});

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>
</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(/\/$/, "");

View File

@@ -36,7 +36,7 @@ const DEFAULT_SETTINGS = {
outboundProxyUrl: "",
outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
rtkEnabled: false,
rtkEnabled: true,
};
function cloneDefaultData() {

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";

View File

@@ -18,7 +18,7 @@ export const FREE_TIER_PROVIDERS = {
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim", notice: { text: "Free access for NVIDIA Developer Program members (prototyping & testing).", apiKeyUrl: "https://build.nvidia.com/settings/api-keys" } },
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/keys" } },
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"] },
gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchConfig: { baseUrl: "https://generativelanguage.googleapis.com", method: "POST", authType: "apikey", authHeader: "x-goog-api-key", costPerQuery: 0, freeMonthlyQuota: 1500, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } },
byteplus: { id: "byteplus", alias: "bpm", name: "BytePlus ModelArk", icon: "cloud", color: "#2563EB", textIcon: "BP", website: "https://console.byteplus.com/ark", notice: { text: "Free credits for new accounts. Access to Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B models.", apiKeyUrl: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey" }, serviceKinds: ["llm"] },
};
@@ -55,22 +55,22 @@ export const OAUTH_PROVIDERS = {
export const APIKEY_PROVIDERS = {
glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" },
"glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM (China)", icon: "code", color: "#DC2626", textIcon: "GC", website: "https://open.bigmodel.cn" },
kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"] },
minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"] },
kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn", serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.moonshot.cn", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } },
minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM", website: "https://www.minimaxi.com", serviceKinds: ["llm", "image", "imageToText", "webSearch"], searchConfig: { baseUrl: "https://api.minimaxi.com", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } },
"minimax-cn": { id: "minimax-cn", alias: "minimax-cn", name: "Minimax (China)", icon: "memory", color: "#DC2626", textIcon: "MC", website: "https://www.minimaxi.com" },
alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
"alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" },
"volcengine-ark": { id: "volcengine-ark", alias: "ark", name: "Volcengine Ark", icon: "cloud", color: "#1677FF", textIcon: "ARK", website: "https://ark.cn-beijing.volces.com" },
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort },
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchConfig: { baseUrl: "https://api.openai.com", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.025, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 15000, cacheTTLMs: 300000 } },
anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm", "imageToText"] },
"opencode-go": { id: "opencode-go", alias: "ocg", name: "OpenCode Go", icon: "terminal", color: "#E87040", textIcon: "OC", website: "https://opencode.ai/auth", notice: { text: "OpenCode Go subscription: $5/mo (then $10/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth" } },
azure: { id: "azure", alias: "azure", name: "Azure OpenAI", icon: "cloud", color: "#0078D4", textIcon: "AZ", website: "https://azure.microsoft.com/en-us/products/ai-services/openai-service", hasProviderSpecificData: true },
deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" },
groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com", serviceKinds: ["llm", "imageToText"] },
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"] },
xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText", "webSearch"], searchConfig: { baseUrl: "https://api.x.ai", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.025, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 30, timeoutMs: 15000, cacheTTLMs: 300000 } },
mistral: { id: "mistral", alias: "mistral", name: "Mistral", icon: "air", color: "#FF7000", textIcon: "MI", website: "https://mistral.ai", serviceKinds: ["llm", "imageToText"] },
perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", serviceKinds: ["llm", "webSearch"] },
perplexity: { id: "perplexity", alias: "pplx", name: "Perplexity", icon: "search", color: "#20808D", textIcon: "PP", website: "https://www.perplexity.ai", serviceKinds: ["llm", "webSearch"], searchConfig: { baseUrl: "https://api.perplexity.ai/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } },
together: { id: "together", alias: "together", name: "Together AI", icon: "group_work", color: "#0F6FFF", textIcon: "TG", website: "https://www.together.ai" },
fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai" },
cerebras: { id: "cerebras", alias: "cerebras", name: "Cerebras", icon: "memory", color: "#FF4F00", textIcon: "CB", website: "https://www.cerebras.ai" },
@@ -94,12 +94,17 @@ export const APIKEY_PROVIDERS = {
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" },
tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", serviceKinds: ["webSearch"] },
"brave-search": { id: "brave-search", alias: "brave", name: "Brave Search", icon: "travel_explore", color: "#FB542B", textIcon: "BR", website: "https://brave.com/search/api", serviceKinds: ["webSearch"] },
serper: { id: "serper", alias: "serper", name: "Serper", icon: "search", color: "#4F46E5", textIcon: "SP", website: "https://serper.dev", serviceKinds: ["webSearch"] },
exa: { id: "exa", alias: "exa", name: "Exa", icon: "manage_search", color: "#2563EB", textIcon: "EX", website: "https://exa.ai", serviceKinds: ["webSearch"] },
searxng: { id: "searxng", alias: "searxng", name: "SearXNG", icon: "saved_search", color: "#3B82F6", textIcon: "SX", website: "https://docs.searxng.org", serviceKinds: ["webSearch"], noAuth: true },
firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", serviceKinds: ["webFetch"] },
tavily: { id: "tavily", alias: "tavily", name: "Tavily", icon: "search", color: "#5B21B6", textIcon: "TV", website: "https://tavily.com", serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.tavily.com/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.tavily.com/extract", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.008, freeMonthlyQuota: 1000, formats: ["markdown", "text"], maxCharacters: 100000, timeoutMs: 15000 } },
"brave-search": { id: "brave-search", alias: "brave", name: "Brave Search", icon: "travel_explore", color: "#FB542B", textIcon: "BR", website: "https://brave.com/search/api", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://api.search.brave.com/res/v1", method: "GET", authType: "apikey", authHeader: "x-subscription-token", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 20, timeoutMs: 10000, cacheTTLMs: 300000 } },
serper: { id: "serper", alias: "serper", name: "Serper", icon: "search", color: "#4F46E5", textIcon: "SP", website: "https://serper.dev", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://google.serper.dev", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.001, freeMonthlyQuota: 2500, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
exa: { id: "exa", alias: "exa", name: "Exa", icon: "manage_search", color: "#2563EB", textIcon: "EX", website: "https://exa.ai", serviceKinds: ["webSearch", "webFetch"], searchConfig: { baseUrl: "https://api.exa.ai/search", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.007, freeMonthlyQuota: 1000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 }, fetchConfig: { baseUrl: "https://api.exa.ai/contents", method: "POST", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.001, freeMonthlyQuota: 1000, formats: ["text", "markdown"], maxCharacters: 100000, timeoutMs: 15000 } },
searxng: { id: "searxng", alias: "searxng", name: "SearXNG", icon: "saved_search", color: "#3B82F6", textIcon: "SX", website: "https://docs.searxng.org", serviceKinds: ["webSearch"], noAuth: true, searchConfig: { baseUrl: "http://localhost:8888/search", method: "GET", authType: "none", authHeader: "none", costPerQuery: 0, freeMonthlyQuota: 999999, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10000, cacheTTLMs: 180000 } },
"google-pse": { id: "google-pse", alias: "gpse", name: "Google PSE", icon: "search", color: "#4285F4", textIcon: "GP", website: "https://programmablesearchengine.google.com", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.googleapis.com/customsearch/v1", method: "GET", authType: "apikey", authHeader: "key", costPerQuery: 0.005, freeMonthlyQuota: 3000, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 10, timeoutMs: 10000, cacheTTLMs: 300000 } },
linkup: { id: "linkup", alias: "linkup", name: "Linkup", icon: "link", color: "#0EA5E9", textIcon: "LK", website: "https://linkup.so", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://api.linkup.so/v1/search", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.005, freeMonthlyQuota: 1000, searchTypes: ["web"], defaultMaxResults: 5, maxMaxResults: 50, timeoutMs: 10000, cacheTTLMs: 300000 } },
searchapi: { id: "searchapi", alias: "searchapi", name: "SearchAPI", icon: "search", color: "#0EA5A4", textIcon: "SA", website: "https://www.searchapi.io", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://www.searchapi.io/api/v1/search", method: "GET", authType: "apikey", authHeader: "api_key", costPerQuery: 0.004, freeMonthlyQuota: 100, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
youcom: { id: "youcom", alias: "youcom", name: "You.com Search", icon: "search", color: "#7C3AED", textIcon: "YC", website: "https://you.com", serviceKinds: ["webSearch"], searchConfig: { baseUrl: "https://ydc-index.io/v1/search", method: "GET", authType: "apikey", authHeader: "x-api-key", costPerQuery: 0.005, freeMonthlyQuota: 0, searchTypes: ["web", "news"], defaultMaxResults: 5, maxMaxResults: 100, timeoutMs: 10000, cacheTTLMs: 300000 } },
firecrawl: { id: "firecrawl", alias: "firecrawl", name: "Firecrawl", icon: "local_fire_department", color: "#F59E0B", textIcon: "FC", website: "https://firecrawl.dev", serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://api.firecrawl.dev/v1/scrape", method: "POST", authType: "apikey", authHeader: "bearer", costPerQuery: 0.002, freeMonthlyQuota: 500, formats: ["markdown", "html", "text"], maxCharacters: 200000, timeoutMs: 30000 } },
"jina-reader": { id: "jina-reader", alias: "jina", name: "Jina Reader", icon: "menu_book", color: "#000000", textIcon: "JR", website: "https://jina.ai/reader", serviceKinds: ["webFetch"], fetchConfig: { baseUrl: "https://r.jina.ai", method: "GET", authType: "apikey", authHeader: "bearer", costPerQuery: 0, freeMonthlyQuota: 1000000, formats: ["markdown", "text", "html"], maxCharacters: 200000, timeoutMs: 30000 } },
};
// Web Cookie Providers (use browser session cookie instead of API key)

View File

@@ -32,9 +32,24 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
// Resolve alias to provider ID (e.g., "kc" -> "kilocode")
const providerId = resolveProviderId(provider);
// Inject a virtual connection for no-auth free providers
// Inject a virtual connection for no-auth free providers (with optional proxy pool from settings)
if (FREE_PROVIDERS[providerId]?.noAuth) {
return { id: "noauth", connectionName: "Public", isActive: true, accessToken: "public" };
const settings = await getSettings();
const override = (settings.providerStrategies || {})[providerId] || {};
const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: override.proxyPoolId || "" });
return {
id: "noauth",
connectionName: "Public",
isActive: true,
accessToken: "public",
providerSpecificData: {
connectionProxyEnabled: resolvedProxy.connectionProxyEnabled,
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
connectionNoProxy: resolvedProxy.connectionNoProxy,
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
},
};
}
const connections = await getProviderConnections({ provider: providerId, isActive: true });