feat: add GitLab Duo and CodeBuddy support, update observability settings
This commit is contained in:
parent
11e6004fcb
commit
abbf8ec86f
21 changed files with 779 additions and 141 deletions
194
src/shared/components/GitLabAuthModal.js
Normal file
194
src/shared/components/GitLabAuthModal.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Modal, Button, Input, OAuthModal } from "@/shared/components";
|
||||
|
||||
const GITLAB_COM = "https://gitlab.com";
|
||||
|
||||
function getRedirectUri() {
|
||||
if (typeof window === "undefined") return "http://localhost/callback";
|
||||
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
|
||||
return `http://localhost:${port}/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitLab Duo Authentication Modal
|
||||
* Supports two modes:
|
||||
* - OAuth (PKCE): requires OAuth App Client ID (and optional Client Secret)
|
||||
* - PAT: requires Personal Access Token
|
||||
*/
|
||||
export default function GitLabAuthModal({ isOpen, providerInfo, onSuccess, onClose }) {
|
||||
const [mode, setMode] = useState(null); // null | "oauth" | "pat"
|
||||
const [baseUrl, setBaseUrl] = useState(GITLAB_COM);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [pat, setPat] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showOAuth, setShowOAuth] = useState(false);
|
||||
const [oauthMeta, setOauthMeta] = useState(null);
|
||||
|
||||
const reset = () => {
|
||||
setMode(null);
|
||||
setBaseUrl(GITLAB_COM);
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
setPat("");
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setShowOAuth(false);
|
||||
setOauthMeta(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOAuthStart = () => {
|
||||
if (!clientId.trim()) {
|
||||
setError("Client ID is required");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setOauthMeta({ baseUrl: baseUrl.trim() || GITLAB_COM, clientId: clientId.trim(), clientSecret: clientSecret.trim() });
|
||||
setShowOAuth(true);
|
||||
};
|
||||
|
||||
const handlePATSubmit = async () => {
|
||||
if (!pat.trim()) {
|
||||
setError("Personal Access Token is required");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/oauth/gitlab/pat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: pat.trim(), baseUrl: baseUrl.trim() || GITLAB_COM }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Authentication failed");
|
||||
onSuccess?.();
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Sub-modal for OAuth PKCE flow
|
||||
if (showOAuth && oauthMeta) {
|
||||
return (
|
||||
<OAuthModal
|
||||
isOpen
|
||||
provider="gitlab"
|
||||
providerInfo={providerInfo}
|
||||
oauthMeta={oauthMeta}
|
||||
onSuccess={() => { onSuccess?.(); handleClose(); }}
|
||||
onClose={() => { setShowOAuth(false); setOauthMeta(null); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title="Connect GitLab Duo" onClose={handleClose} size="lg">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Mode selection */}
|
||||
{!mode && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
Choose how to authenticate with GitLab Duo:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setMode("oauth")}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
>
|
||||
<span className="material-symbols-outlined text-2xl text-primary">lock_open</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">OAuth App</p>
|
||||
<p className="text-xs text-text-muted">Use a GitLab OAuth application</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("pat")}
|
||||
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||
>
|
||||
<span className="material-symbols-outlined text-2xl text-primary">key</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Personal Access Token</p>
|
||||
<p className="text-xs text-text-muted">Use a GitLab PAT with api scope</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* OAuth mode */}
|
||||
{mode === "oauth" && (
|
||||
<>
|
||||
<p className="text-xs text-text-muted">
|
||||
Create an OAuth app at{" "}
|
||||
<a href={`${baseUrl.trim() || GITLAB_COM}/-/profile/applications`} target="_blank" rel="noreferrer" className="text-primary underline">
|
||||
GitLab Applications
|
||||
</a>{" "}
|
||||
with redirect URI{" "}
|
||||
<code className="bg-sidebar px-1 rounded text-xs">{getRedirectUri()}</code>
|
||||
</p>
|
||||
<Input label="GitLab Base URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={GITLAB_COM} />
|
||||
<Input label="Client ID" value={clientId} onChange={(e) => setClientId(e.target.value)} placeholder="Your OAuth application client ID" />
|
||||
<Input label="Client Secret (optional for PKCE)" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} placeholder="Leave empty for public PKCE app" />
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleOAuthStart} fullWidth disabled={!clientId.trim()}>
|
||||
Authorize
|
||||
</Button>
|
||||
<Button onClick={() => { setMode(null); setError(null); }} variant="ghost" fullWidth>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PAT mode */}
|
||||
{mode === "pat" && (
|
||||
<>
|
||||
<p className="text-xs text-text-muted">
|
||||
Create a PAT at{" "}
|
||||
<a href={`${baseUrl.trim() || GITLAB_COM}/-/user_settings/personal_access_tokens`} target="_blank" rel="noreferrer" className="text-primary underline">
|
||||
GitLab Access Tokens
|
||||
</a>{" "}
|
||||
with scopes: <code className="bg-sidebar px-1 rounded text-xs">api</code>,{" "}
|
||||
<code className="bg-sidebar px-1 rounded text-xs">read_user</code>, and{" "}
|
||||
<code className="bg-sidebar px-1 rounded text-xs">ai_features</code>.
|
||||
</p>
|
||||
<Input label="GitLab Base URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={GITLAB_COM} />
|
||||
<Input label="Personal Access Token" value={pat} onChange={(e) => setPat(e.target.value)} placeholder="glpat-xxxxxxxxxxxxxxxxxxxx" type="password" />
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handlePATSubmit} fullWidth disabled={!pat.trim() || loading} loading={loading}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button onClick={() => { setMode(null); setError(null); }} variant="ghost" fullWidth>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
GitLabAuthModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
providerInfo: PropTypes.shape({ name: PropTypes.string }),
|
||||
onSuccess: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
|
@ -10,7 +10,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
|||
* - Localhost: Auto callback via popup message
|
||||
* - Remote: Manual paste callback URL
|
||||
*/
|
||||
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose }) {
|
||||
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, oauthMeta }) {
|
||||
const [step, setStep] = useState("waiting"); // waiting | input | success | error
|
||||
const [authData, setAuthData] = useState(null);
|
||||
const [callbackUrl, setCallbackUrl] = useState("");
|
||||
|
|
@ -51,6 +51,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||
redirectUri: authData.redirectUri,
|
||||
codeVerifier: authData.codeVerifier,
|
||||
state,
|
||||
...(oauthMeta ? { meta: oauthMeta } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +133,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||
setError(null);
|
||||
|
||||
// Device code flow providers
|
||||
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode"];
|
||||
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy"];
|
||||
if (deviceCodeProviders.includes(provider)) {
|
||||
setIsDeviceCode(true);
|
||||
setStep("waiting");
|
||||
|
|
@ -153,18 +154,24 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||
return;
|
||||
}
|
||||
|
||||
// Authorization code flow - always use localhost with current port (except Codex)
|
||||
// Authorization code flow - build redirect URI (some providers require fixed ports)
|
||||
let redirectUri;
|
||||
if (provider === "codex") {
|
||||
// Codex requires fixed port 1455
|
||||
redirectUri = "http://localhost:1455/auth/callback";
|
||||
} else {
|
||||
// Always use localhost with current port for OAuth callback
|
||||
// Use app's current port for OAuth callback
|
||||
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
|
||||
redirectUri = `http://localhost:${port}/callback`;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
|
||||
// Build authorize URL, optionally passing provider-specific metadata (e.g. gitlab clientId)
|
||||
const authorizeUrl = new URL(`/api/oauth/${provider}/authorize`, window.location.origin);
|
||||
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
if (oauthMeta) {
|
||||
Object.entries(oauthMeta).forEach(([k, v]) => { if (v) authorizeUrl.searchParams.set(k, v); });
|
||||
}
|
||||
const res = await fetch(authorizeUrl.toString());
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
|
||||
|
|
@ -462,9 +469,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||
OAuthModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
provider: PropTypes.string,
|
||||
providerInfo: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
}),
|
||||
providerInfo: PropTypes.shape({ name: PropTypes.string }),
|
||||
onSuccess: PropTypes.func,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
/** Extra metadata passed to /authorize and /exchange (e.g. gitlab clientId/baseUrl) */
|
||||
oauthMeta: PropTypes.object,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
|
|||
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
|
||||
export { default as CursorAuthModal } from "./CursorAuthModal";
|
||||
export { default as IFlowCookieModal } from "./IFlowCookieModal";
|
||||
export { default as GitLabAuthModal } from "./GitLabAuthModal";
|
||||
export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,21 @@
|
|||
// Provider definitions
|
||||
|
||||
// Free Providers
|
||||
// Free Providers (kiro first, iflow last)
|
||||
export const FREE_PROVIDERS = {
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Google has tightened Gemini CLI abuse detection and restricted Pro models to paid accounts (Mar 25, 2026). Using this provider may violate ToS and risk account bans." },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
|
||||
};
|
||||
|
||||
// Free Tier Providers (has free access but may require account/API key)
|
||||
export const FREE_TIER_PROVIDERS = {
|
||||
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", passthroughModels: true, website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" } },
|
||||
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/api-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" } },
|
||||
};
|
||||
|
||||
// OAuth Providers
|
||||
|
|
@ -18,11 +28,10 @@ export const OAUTH_PROVIDERS = {
|
|||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
|
||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
|
||||
opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC" },
|
||||
// opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC" },
|
||||
};
|
||||
|
||||
export const APIKEY_PROVIDERS = {
|
||||
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", passthroughModels: true, website: "https://openrouter.ai" },
|
||||
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" },
|
||||
|
|
@ -42,7 +51,6 @@ export const APIKEY_PROVIDERS = {
|
|||
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" },
|
||||
cohere: { id: "cohere", alias: "cohere", name: "Cohere", icon: "hub", color: "#39594D", textIcon: "CO", website: "https://cohere.com" },
|
||||
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim" },
|
||||
nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" },
|
||||
siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" },
|
||||
hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" },
|
||||
|
|
@ -50,9 +58,7 @@ export const APIKEY_PROVIDERS = {
|
|||
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
|
||||
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
|
||||
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
|
||||
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
||||
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
|
||||
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai" },
|
||||
"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" },
|
||||
};
|
||||
|
||||
|
|
@ -68,7 +74,7 @@ export function isAnthropicCompatibleProvider(providerId) {
|
|||
}
|
||||
|
||||
// All providers (combined)
|
||||
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
|
||||
|
||||
// Auth methods
|
||||
export const AUTH_METHODS = {
|
||||
|
|
|
|||
45
src/shared/utils/providerModelsFetcher.js
Normal file
45
src/shared/utils/providerModelsFetcher.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Fetch and cache suggested models for providers that expose a public models API
|
||||
// Designed to be extensible: add new types in FILTERS below
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const cache = new Map(); // key: fetcher.url → { data, expiresAt }
|
||||
|
||||
const FILTERS = {
|
||||
// Free models with context >= 200k tokens
|
||||
"openrouter-free": (models) =>
|
||||
models
|
||||
.filter(
|
||||
(m) =>
|
||||
m.pricing?.prompt === "0" &&
|
||||
m.pricing?.completion === "0" &&
|
||||
m.context_length >= 200000
|
||||
)
|
||||
.map((m) => ({ id: m.id, name: m.name, contextLength: m.context_length }))
|
||||
.sort((a, b) => b.contextLength - a.contextLength),
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch suggested models for a provider using its modelsFetcher config.
|
||||
* Results are cached in-memory for CACHE_TTL_MS.
|
||||
* @param {{ url: string, type: string }} fetcher
|
||||
* @returns {Promise<Array<{ id: string, name: string, contextLength: number }>>}
|
||||
*/
|
||||
export async function fetchSuggestedModels(fetcher) {
|
||||
if (!fetcher?.url || !fetcher?.type) return [];
|
||||
|
||||
const cached = cache.get(fetcher.url);
|
||||
if (cached && Date.now() < cached.expiresAt) return cached.data;
|
||||
|
||||
try {
|
||||
const res = await fetch(fetcher.url);
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const raw = json.data ?? json.models ?? json;
|
||||
const filter = FILTERS[fetcher.type];
|
||||
const data = filter ? filter(Array.isArray(raw) ? raw : []) : [];
|
||||
cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
return data;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue