mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: add GitLab Duo and CodeBuddy support, update observability settings
This commit is contained in:
@@ -212,7 +212,7 @@ export const PROVIDER_MODELS = {
|
|||||||
{ id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" },
|
{ id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" },
|
||||||
],
|
],
|
||||||
openrouter: [
|
openrouter: [
|
||||||
{ id: "auto", name: "Auto (Best Available)" },
|
// { id: "openrouter/free", name: "Free Models (Auto)" },
|
||||||
],
|
],
|
||||||
glm: [
|
glm: [
|
||||||
{ id: "glm-5", name: "GLM 5" },
|
{ id: "glm-5", name: "GLM 5" },
|
||||||
|
|||||||
@@ -316,4 +316,14 @@ export const PROVIDERS = {
|
|||||||
baseUrl: "https://aiplatform.googleapis.com",
|
baseUrl: "https://aiplatform.googleapis.com",
|
||||||
format: "openai"
|
format: "openai"
|
||||||
},
|
},
|
||||||
|
// GitLab Duo - OpenAI-compatible chat endpoint
|
||||||
|
gitlab: {
|
||||||
|
baseUrl: "https://gitlab.com/api/v4/chat/completions",
|
||||||
|
format: "openai",
|
||||||
|
},
|
||||||
|
// CodeBuddy (Tencent) - uses device_code polling auth, no chat completions baseUrl needed
|
||||||
|
codebuddy: {
|
||||||
|
baseUrl: "https://copilot.tencent.com/v1/chat/completions",
|
||||||
|
format: "openai",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export class DefaultExecutor extends BaseExecutor {
|
|||||||
if (!headers["anthropic-version"]) {
|
if (!headers["anthropic-version"]) {
|
||||||
headers["anthropic-version"] = "2023-06-01";
|
headers["anthropic-version"] = "2023-06-01";
|
||||||
}
|
}
|
||||||
|
} else if (this.provider === "gitlab") {
|
||||||
|
// GitLab Duo uses Bearer token (PAT with ai_features scope, or OAuth access token)
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
|
} else if (this.provider === "codebuddy") {
|
||||||
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
} else if (this.provider === "kilocode") {
|
} else if (this.provider === "kilocode") {
|
||||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||||
if (credentials.providerSpecificData?.orgId) {
|
if (credentials.providerSpecificData?.orgId) {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
|||||||
function: {
|
function: {
|
||||||
name,
|
name,
|
||||||
description: String(tool.description || ""),
|
description: String(tool.description || ""),
|
||||||
parameters: tool.parameters,
|
parameters: normalizeToolParameters(tool.parameters),
|
||||||
strict: tool.strict
|
strict: tool.strict
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -158,6 +158,15 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure object schema always has properties field (required by Codex Responses API)
|
||||||
|
*/
|
||||||
|
function normalizeToolParameters(params) {
|
||||||
|
if (!params) return { type: "object", properties: {} };
|
||||||
|
if (params.type === "object" && !params.properties) return { ...params, properties: {} };
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert OpenAI Chat Completions to OpenAI Responses API format
|
* Convert OpenAI Chat Completions to OpenAI Responses API format
|
||||||
*/
|
*/
|
||||||
@@ -226,7 +235,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
|||||||
result.input.push({
|
result.input.push({
|
||||||
type: "function_call",
|
type: "function_call",
|
||||||
call_id: clampCallId(tc.id),
|
call_id: clampCallId(tc.id),
|
||||||
name: tc.function?.name || "",
|
name: tc.function?.name || "_unknown",
|
||||||
arguments: tc.function?.arguments || "{}"
|
arguments: tc.function?.arguments || "{}"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -260,7 +269,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
|||||||
type: "function",
|
type: "function",
|
||||||
name: tool.function.name,
|
name: tool.function.name,
|
||||||
description: String(tool.function.description || ""),
|
description: String(tool.function.description || ""),
|
||||||
parameters: tool.function.parameters,
|
parameters: normalizeToolParameters(tool.function.parameters),
|
||||||
strict: tool.function.strict
|
strict: tool.function.strict
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -187,7 +187,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{!hasActiveProviders && (
|
{/* {!hasActiveProviders && (
|
||||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||||
@@ -197,7 +197,7 @@ export default function CLIToolsPageClient({ machineId }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)} */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
|
{regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -243,13 +243,13 @@ export default function ProfilePage() {
|
|||||||
const res = await fetch("/api/settings", {
|
const res = await fetch("/api/settings", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ observabilityEnabled: enabled }),
|
body: JSON.stringify({ enableObservability: enabled }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSettings(prev => ({ ...prev, observabilityEnabled: enabled }));
|
setSettings(prev => ({ ...prev, enableObservability: enabled }));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update observabilityEnabled:", err);
|
console.error("Failed to update enableObservability:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const observabilityEnabled = settings.observabilityEnabled === true;
|
const observabilityEnabled = settings.enableObservability === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import PropTypes from "prop-types";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, Toggle, Select } from "@/shared/components";
|
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||||
|
|
||||||
export default function ProviderDetailPage() {
|
export default function ProviderDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -36,6 +37,7 @@ export default function ProviderDetailPage() {
|
|||||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||||
const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override
|
const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override
|
||||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||||
|
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||||
const { copied, copy } = useCopyToClipboard();
|
const { copied, copy } = useCopyToClipboard();
|
||||||
|
|
||||||
const providerInfo = providerNode
|
const providerInfo = providerNode
|
||||||
@@ -48,7 +50,7 @@ export default function ProviderDetailPage() {
|
|||||||
baseUrl: providerNode.baseUrl,
|
baseUrl: providerNode.baseUrl,
|
||||||
type: providerNode.type,
|
type: providerNode.type,
|
||||||
}
|
}
|
||||||
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId]);
|
: (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId]);
|
||||||
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId];
|
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId];
|
||||||
const models = getModelsByProviderId(providerId);
|
const models = getModelsByProviderId(providerId);
|
||||||
const providerAlias = getProviderAlias(providerId);
|
const providerAlias = getProviderAlias(providerId);
|
||||||
@@ -189,6 +191,13 @@ export default function ProviderDetailPage() {
|
|||||||
fetchAliases();
|
fetchAliases();
|
||||||
}, [fetchConnections, fetchAliases]);
|
}, [fetchConnections, fetchAliases]);
|
||||||
|
|
||||||
|
// Fetch suggested models from provider's public API (if configured)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher;
|
||||||
|
if (!fetcher) return;
|
||||||
|
fetchSuggestedModels(fetcher).then(setSuggestedModels);
|
||||||
|
}, [providerId]);
|
||||||
|
|
||||||
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
|
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
|
||||||
const fullModel = `${providerAliasOverride}/${modelId}`;
|
const fullModel = `${providerAliasOverride}/${modelId}`;
|
||||||
try {
|
try {
|
||||||
@@ -528,18 +537,6 @@ export default function ProviderDetailPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (providerInfo.passthroughModels) {
|
|
||||||
return (
|
|
||||||
<PassthroughModelsSection
|
|
||||||
providerAlias={providerAlias}
|
|
||||||
modelAliases={modelAliases}
|
|
||||||
copied={copied}
|
|
||||||
onCopy={copy}
|
|
||||||
onSetAlias={handleSetAlias}
|
|
||||||
onDeleteAlias={handleDeleteAlias}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
|
// Custom models added by user (stored as aliases: modelId → providerAlias/modelId)
|
||||||
const customModels = Object.entries(modelAliases)
|
const customModels = Object.entries(modelAliases)
|
||||||
.filter(([alias, fullModel]) => {
|
.filter(([alias, fullModel]) => {
|
||||||
@@ -547,6 +544,8 @@ export default function ProviderDetailPage() {
|
|||||||
if (!fullModel.startsWith(prefix)) return false;
|
if (!fullModel.startsWith(prefix)) return false;
|
||||||
const modelId = fullModel.slice(prefix.length);
|
const modelId = fullModel.slice(prefix.length);
|
||||||
// Only show if not already in hardcoded list
|
// Only show if not already in hardcoded list
|
||||||
|
// For passthroughModels, include all aliases (model IDs may contain slashes like "anthropic/claude-3")
|
||||||
|
if (providerInfo.passthroughModels) return !models.some((m) => m.id === modelId);
|
||||||
return !models.some((m) => m.id === modelId) && alias === modelId;
|
return !models.some((m) => m.id === modelId) && alias === modelId;
|
||||||
})
|
})
|
||||||
.map(([alias, fullModel]) => ({
|
.map(([alias, fullModel]) => ({
|
||||||
@@ -606,6 +605,36 @@ export default function ProviderDetailPage() {
|
|||||||
<span className="material-symbols-outlined text-sm">add</span>
|
<span className="material-symbols-outlined text-sm">add</span>
|
||||||
Add Model
|
Add Model
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Suggested models from provider API — show only models not yet added */}
|
||||||
|
{suggestedModels.length > 0 && (() => {
|
||||||
|
const addedFullModels = new Set(Object.values(modelAliases));
|
||||||
|
const notAdded = suggestedModels.filter(
|
||||||
|
(m) => !addedFullModels.has(`${providerStorageAlias}/${m.id}`)
|
||||||
|
);
|
||||||
|
if (notAdded.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="w-full mt-2">
|
||||||
|
<p className="text-xs text-text-muted mb-2">Suggested free models (≥200k context):</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{notAdded.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={async () => {
|
||||||
|
const alias = m.id.split("/").pop();
|
||||||
|
await handleSetAlias(m.id, alias, providerStorageAlias);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
|
||||||
|
title={`${m.name} · ${(m.contextLength / 1000).toFixed(0)}k ctx`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[13px]">add</span>
|
||||||
|
{m.id.split("/").pop()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -689,6 +718,23 @@ export default function ProviderDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{providerInfo.notice && !providerInfo.deprecated && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-black/[0.02] dark:bg-white/[0.02] border border-black/[0.05] dark:border-white/[0.05]">
|
||||||
|
<span className="material-symbols-outlined text-[16px] text-text-muted shrink-0">info</span>
|
||||||
|
<p className="text-xs text-text-muted leading-relaxed flex-1">{providerInfo.notice.text}</p>
|
||||||
|
{providerInfo.notice.apiKeyUrl && (
|
||||||
|
<a
|
||||||
|
href={providerInfo.notice.apiKeyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
Get API Key →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isCompatible && providerNode && (
|
{isCompatible && providerNode && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -824,7 +870,7 @@ export default function ProviderDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
{"Available Models"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{!!modelsTestError && (
|
{!!modelsTestError && (
|
||||||
@@ -849,6 +895,13 @@ export default function ProviderDetailPage() {
|
|||||||
onSuccess={handleOAuthSuccess}
|
onSuccess={handleOAuthSuccess}
|
||||||
onClose={() => setShowOAuthModal(false)}
|
onClose={() => setShowOAuthModal(false)}
|
||||||
/>
|
/>
|
||||||
|
) : providerId === "gitlab" ? (
|
||||||
|
<GitLabAuthModal
|
||||||
|
isOpen={showOAuthModal}
|
||||||
|
providerInfo={providerInfo}
|
||||||
|
onSuccess={handleOAuthSuccess}
|
||||||
|
onClose={() => setShowOAuthModal(false)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<OAuthModal
|
<OAuthModal
|
||||||
isOpen={showOAuthModal}
|
isOpen={showOAuthModal}
|
||||||
@@ -891,13 +944,17 @@ export default function ProviderDetailPage() {
|
|||||||
isAnthropic={isAnthropicCompatible}
|
isAnthropic={isAnthropicCompatible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isCompatible && !providerInfo?.passthroughModels && (
|
{!isCompatible && (
|
||||||
<AddCustomModelModal
|
<AddCustomModelModal
|
||||||
isOpen={showAddCustomModel}
|
isOpen={showAddCustomModel}
|
||||||
providerAlias={providerStorageAlias}
|
providerAlias={providerStorageAlias}
|
||||||
providerDisplayAlias={providerDisplayAlias}
|
providerDisplayAlias={providerDisplayAlias}
|
||||||
onSave={async (modelId) => {
|
onSave={async (modelId) => {
|
||||||
await handleSetAlias(modelId, modelId, providerStorageAlias);
|
// For passthrough providers (OpenRouter), use last segment as alias to avoid slash conflicts
|
||||||
|
const alias = providerInfo?.passthroughModels
|
||||||
|
? modelId.split("/").pop()
|
||||||
|
: modelId;
|
||||||
|
await handleSetAlias(modelId, alias, providerStorageAlias);
|
||||||
setShowAddCustomModel(false);
|
setShowAddCustomModel(false);
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowAddCustomModel(false)}
|
onClose={() => setShowAddCustomModel(false)}
|
||||||
@@ -931,26 +988,34 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCusto
|
|||||||
</span>
|
</span>
|
||||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||||
{onTest && (
|
{onTest && (
|
||||||
|
<div className="relative group/btn">
|
||||||
<button
|
<button
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
disabled={isTesting}
|
disabled={isTesting}
|
||||||
className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||||
title="Test model"
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||||
{isTesting ? "progress_activity" : "science"}
|
{isTesting ? "progress_activity" : "science"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||||
|
{isTesting ? "Testing..." : "Test"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative group/btn">
|
||||||
<button
|
<button
|
||||||
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
||||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||||
title="Copy model"
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm">
|
<span className="material-symbols-outlined text-sm">
|
||||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||||
|
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{isCustom && (
|
{isCustom && (
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteAlias}
|
onClick={onDeleteAlias}
|
||||||
@@ -1103,26 +1168,34 @@ function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias
|
|||||||
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||||
|
<div className="relative group/btn">
|
||||||
<button
|
<button
|
||||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||||
title="Copy model"
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm">
|
<span className="material-symbols-outlined text-sm">
|
||||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||||
|
{copied === `model-${modelId}` ? "Copied!" : "Copy"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{onTest && (
|
{onTest && (
|
||||||
|
<div className="relative group/btn">
|
||||||
<button
|
<button
|
||||||
onClick={onTest}
|
onClick={onTest}
|
||||||
disabled={isTesting}
|
disabled={isTesting}
|
||||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
|
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
|
||||||
title="Test model"
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||||
{isTesting ? "progress_activity" : "science"}
|
{isTesting ? "progress_activity" : "science"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||||
|
{isTesting ? "Testing..." : "Test"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
|||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
import {
|
import {
|
||||||
FREE_PROVIDERS,
|
FREE_PROVIDERS,
|
||||||
|
FREE_TIER_PROVIDERS,
|
||||||
OPENAI_COMPATIBLE_PREFIX,
|
OPENAI_COMPATIBLE_PREFIX,
|
||||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||||
} from "@/shared/constants/providers";
|
} from "@/shared/constants/providers";
|
||||||
@@ -286,11 +287,11 @@ export default function ProvidersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Free Providers */}
|
{/* Free & Free Tier Providers */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||||
Free Providers
|
Free & Free Tier Providers
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBatchTest("free")}
|
onClick={() => handleBatchTest("free")}
|
||||||
@@ -322,6 +323,16 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{Object.entries(FREE_TIER_PROVIDERS).map(([key, info]) => (
|
||||||
|
<ApiKeyProviderCard
|
||||||
|
key={key}
|
||||||
|
providerId={key}
|
||||||
|
provider={info}
|
||||||
|
stats={getProviderStats(key, "apikey")}
|
||||||
|
authType="apikey"
|
||||||
|
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export async function GET(request, { params }) {
|
|||||||
|
|
||||||
if (action === "authorize") {
|
if (action === "authorize") {
|
||||||
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
|
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
|
||||||
const authData = generateAuthData(provider, redirectUri);
|
// Collect provider-specific meta params (e.g. gitlab passes baseUrl, clientId, clientSecret)
|
||||||
|
const reservedParams = new Set(["redirect_uri"]);
|
||||||
|
const meta = {};
|
||||||
|
searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; });
|
||||||
|
const authData = generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
|
||||||
return NextResponse.json(authData);
|
return NextResponse.json(authData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ export async function GET(request, { params }) {
|
|||||||
const authData = generateAuthData(provider, null);
|
const authData = generateAuthData(provider, null);
|
||||||
|
|
||||||
// Providers that don't use PKCE for device code
|
// Providers that don't use PKCE for device code
|
||||||
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode"];
|
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy"];
|
||||||
let deviceData;
|
let deviceData;
|
||||||
if (noPkceDeviceProviders.includes(provider)) {
|
if (noPkceDeviceProviders.includes(provider)) {
|
||||||
deviceData = await requestDeviceCode(provider);
|
deviceData = await requestDeviceCode(provider);
|
||||||
@@ -70,7 +74,7 @@ export async function POST(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "exchange") {
|
if (action === "exchange") {
|
||||||
const { code, redirectUri, codeVerifier, state } = body;
|
const { code, redirectUri, codeVerifier, state, meta } = body;
|
||||||
|
|
||||||
// Cline uses authorization_code without PKCE
|
// Cline uses authorization_code without PKCE
|
||||||
const noPkceExchangeProviders = ["cline"];
|
const noPkceExchangeProviders = ["cline"];
|
||||||
@@ -78,8 +82,8 @@ export async function POST(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange code for tokens
|
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl)
|
||||||
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state);
|
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta);
|
||||||
|
|
||||||
// Save to database
|
// Save to database
|
||||||
const connection = await createProviderConnection({
|
const connection = await createProviderConnection({
|
||||||
@@ -111,7 +115,7 @@ export async function POST(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Providers that don't use PKCE for device code
|
// Providers that don't use PKCE for device code
|
||||||
const noPkceProviders = ["github", "kimi-coding", "kilocode"];
|
const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy"];
|
||||||
let result;
|
let result;
|
||||||
if (noPkceProviders.includes(provider)) {
|
if (noPkceProviders.includes(provider)) {
|
||||||
result = await pollForToken(provider, deviceCode);
|
result = await pollForToken(provider, deviceCode);
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createProviderConnection } from "@/models";
|
||||||
|
|
||||||
|
const GITLAB_DEFAULT_BASE = "https://gitlab.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/oauth/gitlab/pat
|
||||||
|
* Authenticate GitLab Duo with a Personal Access Token (PAT)
|
||||||
|
*/
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, baseUrl } = body;
|
||||||
|
if (!token?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Personal Access Token is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = (baseUrl?.trim() || GITLAB_DEFAULT_BASE).replace(/\/$/, "");
|
||||||
|
|
||||||
|
// Verify token by fetching current user
|
||||||
|
const userRes = await fetch(`${base}/api/v4/user`, {
|
||||||
|
headers: { "Private-Token": token.trim(), Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRes.ok) {
|
||||||
|
const err = await userRes.text();
|
||||||
|
return NextResponse.json({ error: `GitLab token verification failed: ${err}` }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await userRes.json();
|
||||||
|
const email = user.email || user.public_email || "";
|
||||||
|
|
||||||
|
await createProviderConnection({
|
||||||
|
provider: "gitlab",
|
||||||
|
authType: "oauth",
|
||||||
|
accessToken: token.trim(),
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
email,
|
||||||
|
displayName: user.name || user.username || email,
|
||||||
|
testStatus: "active",
|
||||||
|
providerSpecificData: {
|
||||||
|
username: user.username || "",
|
||||||
|
email,
|
||||||
|
name: user.name || "",
|
||||||
|
baseUrl: base,
|
||||||
|
authKind: "personal_access_token",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("GitLab PAT auth error:", error);
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,14 @@ const OAUTH_TEST_CONFIG = {
|
|||||||
authPrefix: "Bearer ",
|
authPrefix: "Bearer ",
|
||||||
},
|
},
|
||||||
cline: { refreshable: true },
|
cline: { refreshable: true },
|
||||||
|
gitlab: {
|
||||||
|
// Test by hitting the GitLab user API — requires api or read_user scope
|
||||||
|
url: "https://gitlab.com/api/v4/user",
|
||||||
|
method: "GET",
|
||||||
|
authHeader: "Authorization",
|
||||||
|
authPrefix: "Bearer ",
|
||||||
|
},
|
||||||
|
codebuddy: { tokenExists: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async function probeClineAccessToken(accessToken) {
|
async function probeClineAccessToken(accessToken) {
|
||||||
|
|||||||
+70
-32
@@ -40,6 +40,11 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) {
|
|||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed db.json with defaults on first run so proper-lockfile never hits ENOENT
|
||||||
|
if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) {
|
||||||
|
fs.writeFileSync(DB_FILE, JSON.stringify(defaultData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
// Default data structure
|
// Default data structure
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
providerConnections: [],
|
providerConnections: [],
|
||||||
@@ -58,7 +63,7 @@ const defaultData = {
|
|||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
enableObservability: false,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
observabilityFlushIntervalMs: 5000,
|
observabilityFlushIntervalMs: 5000,
|
||||||
@@ -88,7 +93,7 @@ function cloneDefaultData() {
|
|||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
observabilityEnabled: true,
|
enableObservability: false,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
observabilityFlushIntervalMs: 5000,
|
observabilityFlushIntervalMs: 5000,
|
||||||
@@ -162,6 +167,19 @@ function ensureDbShape(data) {
|
|||||||
// Singleton instance
|
// Singleton instance
|
||||||
let dbInstance = null;
|
let dbInstance = null;
|
||||||
|
|
||||||
|
// In-memory read cache to avoid redundant disk reads under high load
|
||||||
|
const DB_CACHE_TTL = 500; // ms
|
||||||
|
let dbCache = { data: null, ts: 0 };
|
||||||
|
|
||||||
|
// Serialize all DB operations (reads on cache-miss + writes) to prevent race conditions
|
||||||
|
let dbQueue = Promise.resolve();
|
||||||
|
|
||||||
|
function withDbLock(fn) {
|
||||||
|
const next = dbQueue.then(fn, fn);
|
||||||
|
dbQueue = next.catch(() => {});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
// Lock options for proper-lockfile
|
// Lock options for proper-lockfile
|
||||||
const LOCK_OPTIONS = {
|
const LOCK_OPTIONS = {
|
||||||
retries: {
|
retries: {
|
||||||
@@ -204,7 +222,8 @@ async function safeRead(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely write database with file locking
|
* Safely write database with file locking.
|
||||||
|
* Always invalidates read cache so next read reflects the latest state.
|
||||||
*/
|
*/
|
||||||
async function safeWrite(db) {
|
async function safeWrite(db) {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
@@ -214,9 +233,10 @@ async function safeWrite(db) {
|
|||||||
|
|
||||||
let release = null;
|
let release = null;
|
||||||
try {
|
try {
|
||||||
// Acquire lock before writing
|
|
||||||
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
||||||
await db.write();
|
await db.write();
|
||||||
|
// Invalidate cache immediately after a successful write
|
||||||
|
dbCache.ts = 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "ELOCKED") {
|
if (error.code === "ELOCKED") {
|
||||||
console.warn("[DB] File is locked, retrying write...");
|
console.warn("[DB] File is locked, retrying write...");
|
||||||
@@ -235,11 +255,14 @@ async function safeWrite(db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database instance (singleton)
|
* Get database instance (singleton).
|
||||||
|
*
|
||||||
|
* Hot path: if cache is fresh, return immediately without any I/O or queuing.
|
||||||
|
* Cold path: serialize via withDbLock to prevent concurrent reads from racing
|
||||||
|
* against in-flight writes (eliminates lost-update race condition).
|
||||||
*/
|
*/
|
||||||
export async function getDb() {
|
export async function getDb() {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
// Return in-memory DB for Workers
|
|
||||||
if (!dbInstance) {
|
if (!dbInstance) {
|
||||||
const data = cloneDefaultData();
|
const data = cloneDefaultData();
|
||||||
dbInstance = new Low({ read: async () => {}, write: async () => {} }, data);
|
dbInstance = new Low({ read: async () => {}, write: async () => {} }, data);
|
||||||
@@ -248,17 +271,34 @@ export async function getDb() {
|
|||||||
return dbInstance;
|
return dbInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hot path: cache hit — no lock, no disk I/O
|
||||||
|
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
|
||||||
|
if (!dbInstance) {
|
||||||
|
const adapter = new JSONFile(DB_FILE);
|
||||||
|
dbInstance = new Low(adapter, dbCache.data);
|
||||||
|
}
|
||||||
|
dbInstance.data = dbCache.data;
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold path: serialize with writes to prevent race conditions
|
||||||
|
return withDbLock(async () => {
|
||||||
|
// Re-check cache inside lock — another queued task may have already loaded it
|
||||||
|
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
|
||||||
|
dbInstance.data = dbCache.data;
|
||||||
|
return dbInstance;
|
||||||
|
}
|
||||||
|
|
||||||
if (!dbInstance) {
|
if (!dbInstance) {
|
||||||
const adapter = new JSONFile(DB_FILE);
|
const adapter = new JSONFile(DB_FILE);
|
||||||
dbInstance = new Low(adapter, cloneDefaultData());
|
dbInstance = new Low(adapter, cloneDefaultData());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always read latest disk state to avoid stale singleton data across route workers.
|
|
||||||
try {
|
try {
|
||||||
await safeRead(dbInstance);
|
await safeRead(dbInstance);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
console.warn('[DB] Corrupt JSON detected, resetting to defaults...');
|
console.warn("[DB] Corrupt JSON detected, resetting to defaults...");
|
||||||
dbInstance.data = cloneDefaultData();
|
dbInstance.data = cloneDefaultData();
|
||||||
await safeWrite(dbInstance);
|
await safeWrite(dbInstance);
|
||||||
} else {
|
} else {
|
||||||
@@ -266,19 +306,22 @@ export async function getDb() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize/migrate missing keys for older DB schema versions.
|
// Initialize/migrate missing keys for older DB schema versions
|
||||||
if (!dbInstance.data) {
|
if (!dbInstance.data) {
|
||||||
dbInstance.data = cloneDefaultData();
|
dbInstance.data = cloneDefaultData();
|
||||||
await safeWrite(dbInstance);
|
await safeWrite(dbInstance);
|
||||||
} else {
|
} else {
|
||||||
const { data, changed } = ensureDbShape(dbInstance.data);
|
const { data, changed } = ensureDbShape(dbInstance.data);
|
||||||
dbInstance.data = data;
|
dbInstance.data = data;
|
||||||
if (changed) {
|
if (changed) await safeWrite(dbInstance);
|
||||||
await safeWrite(dbInstance);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update cache after successful read
|
||||||
|
dbCache.data = dbInstance.data;
|
||||||
|
dbCache.ts = Date.now();
|
||||||
|
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Provider Connections ============
|
// ============ Provider Connections ============
|
||||||
@@ -608,10 +651,7 @@ export async function createProviderConnection(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.data.providerConnections.push(connection);
|
db.data.providerConnections.push(connection);
|
||||||
await safeWrite(db);
|
await reorderProviderConnections(data.provider, db);
|
||||||
|
|
||||||
// Reorder to ensure consistency
|
|
||||||
await reorderProviderConnections(data.provider);
|
|
||||||
|
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
@@ -633,11 +673,11 @@ export async function updateProviderConnection(id, data) {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await safeWrite(db);
|
// Reorder if priority was changed, reuse same db instance to avoid double-read
|
||||||
|
|
||||||
// Reorder if priority was changed
|
|
||||||
if (data.priority !== undefined) {
|
if (data.priority !== undefined) {
|
||||||
await reorderProviderConnections(providerId);
|
await reorderProviderConnections(providerId, db);
|
||||||
|
} else {
|
||||||
|
await safeWrite(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.data.providerConnections[index];
|
return db.data.providerConnections[index];
|
||||||
@@ -655,37 +695,35 @@ export async function deleteProviderConnection(id) {
|
|||||||
const providerId = db.data.providerConnections[index].provider;
|
const providerId = db.data.providerConnections[index].provider;
|
||||||
|
|
||||||
db.data.providerConnections.splice(index, 1);
|
db.data.providerConnections.splice(index, 1);
|
||||||
await safeWrite(db);
|
// Reorder to fill gaps, reuse same db instance to avoid double-read
|
||||||
|
await reorderProviderConnections(providerId, db);
|
||||||
// Reorder to fill gaps
|
|
||||||
await reorderProviderConnections(providerId);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder provider connections to ensure unique, sequential priorities
|
* Reorder provider connections to ensure unique, sequential priorities.
|
||||||
|
* Accepts an existing db instance to avoid redundant getDb() calls and
|
||||||
|
* prevent double-read race conditions within the same write operation.
|
||||||
*/
|
*/
|
||||||
export async function reorderProviderConnections(providerId) {
|
export async function reorderProviderConnections(providerId, db) {
|
||||||
const db = await getDb();
|
const instance = db || (await getDb());
|
||||||
if (!db.data.providerConnections) return;
|
if (!instance.data.providerConnections) return;
|
||||||
|
|
||||||
const providerConnections = db.data.providerConnections
|
const providerConnections = instance.data.providerConnections
|
||||||
.filter(c => c.provider === providerId)
|
.filter(c => c.provider === providerId)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by priority first
|
|
||||||
const pDiff = (a.priority || 0) - (b.priority || 0);
|
const pDiff = (a.priority || 0) - (b.priority || 0);
|
||||||
if (pDiff !== 0) return pDiff;
|
if (pDiff !== 0) return pDiff;
|
||||||
// Use updatedAt as tie-breaker (newer first)
|
// Use updatedAt as tie-breaker (newer first)
|
||||||
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-assign sequential priorities
|
|
||||||
providerConnections.forEach((conn, index) => {
|
providerConnections.forEach((conn, index) => {
|
||||||
conn.priority = index + 1;
|
conn.priority = index + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
await safeWrite(db);
|
await safeWrite(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Model Aliases ============
|
// ============ Model Aliases ============
|
||||||
|
|||||||
@@ -216,6 +216,31 @@ export const CLINE_CONFIG = {
|
|||||||
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
|
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GitLab Duo OAuth Configuration (Authorization Code Flow with PKCE)
|
||||||
|
// Supports both OAuth (PKCE) and Personal Access Token (PAT) modes
|
||||||
|
export const GITLAB_CONFIG = {
|
||||||
|
defaultBaseUrl: "https://gitlab.com",
|
||||||
|
authorizeUrlPath: "/oauth/authorize",
|
||||||
|
tokenUrlPath: "/oauth/token",
|
||||||
|
userInfoUrlPath: "/api/v4/user",
|
||||||
|
scope: "api read_user",
|
||||||
|
codeChallengeMethod: "S256",
|
||||||
|
};
|
||||||
|
|
||||||
|
// CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow)
|
||||||
|
// Step 1: POST /v2/plugin/auth/state?platform=CLI → get { state, authUrl }
|
||||||
|
// Step 2: Open authUrl in browser
|
||||||
|
// Step 3: Poll POST /v2/plugin/auth/token with state until success
|
||||||
|
export const CODEBUDDY_CONFIG = {
|
||||||
|
baseUrl: "https://copilot.tencent.com",
|
||||||
|
stateUrl: "https://copilot.tencent.com/v2/plugin/auth/state",
|
||||||
|
tokenUrl: "https://copilot.tencent.com/v2/plugin/auth/token",
|
||||||
|
refreshUrl: "https://copilot.tencent.com/v2/plugin/auth/token/refresh",
|
||||||
|
userAgent: "CLI/2.63.2 CodeBuddy/2.63.2",
|
||||||
|
platform: "CLI",
|
||||||
|
pollInterval: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
// OAuth timeout (5 minutes)
|
// OAuth timeout (5 minutes)
|
||||||
export const OAUTH_TIMEOUT = 300000;
|
export const OAUTH_TIMEOUT = 300000;
|
||||||
|
|
||||||
@@ -234,4 +259,6 @@ export const PROVIDERS = {
|
|||||||
KIMI_CODING: "kimi-coding",
|
KIMI_CODING: "kimi-coding",
|
||||||
KILOCODE: "kilocode",
|
KILOCODE: "kilocode",
|
||||||
CLINE: "cline",
|
CLINE: "cline",
|
||||||
|
GITLAB: "gitlab",
|
||||||
|
CODEBUDDY: "codebuddy",
|
||||||
};
|
};
|
||||||
|
|||||||
+143
-5
@@ -20,6 +20,8 @@ import {
|
|||||||
KIMI_CODING_CONFIG,
|
KIMI_CODING_CONFIG,
|
||||||
KILOCODE_CONFIG,
|
KILOCODE_CONFIG,
|
||||||
CLINE_CONFIG,
|
CLINE_CONFIG,
|
||||||
|
GITLAB_CONFIG,
|
||||||
|
CODEBUDDY_CONFIG,
|
||||||
} from "./constants/oauth";
|
} from "./constants/oauth";
|
||||||
|
|
||||||
// Provider configurations
|
// Provider configurations
|
||||||
@@ -873,6 +875,140 @@ const PROVIDERS = {
|
|||||||
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
|
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// GitLab Duo - Authorization Code Flow with PKCE
|
||||||
|
// Supports two login modes via loginMode metadata: "oauth" (default) or "pat"
|
||||||
|
gitlab: {
|
||||||
|
config: GITLAB_CONFIG,
|
||||||
|
flowType: "authorization_code_pkce",
|
||||||
|
buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => {
|
||||||
|
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
|
||||||
|
const clientId = meta.clientId || "";
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
state,
|
||||||
|
scope: config.scope,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: config.codeChallengeMethod,
|
||||||
|
});
|
||||||
|
return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`;
|
||||||
|
},
|
||||||
|
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => {
|
||||||
|
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
|
||||||
|
const clientId = meta.clientId || "";
|
||||||
|
const clientSecret = meta.clientSecret || "";
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
if (clientSecret) body.set("client_secret", clientSecret);
|
||||||
|
const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`);
|
||||||
|
const tokens = await response.json();
|
||||||
|
// Fetch user info
|
||||||
|
const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
});
|
||||||
|
const user = userRes.ok ? await userRes.json() : {};
|
||||||
|
return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId };
|
||||||
|
},
|
||||||
|
mapTokens: (tokens) => ({
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresIn: tokens.expires_in,
|
||||||
|
scope: tokens.scope,
|
||||||
|
providerSpecificData: {
|
||||||
|
username: tokens._user?.username || "",
|
||||||
|
email: tokens._user?.email || tokens._user?.public_email || "",
|
||||||
|
name: tokens._user?.name || "",
|
||||||
|
baseUrl: tokens._baseUrl,
|
||||||
|
clientId: tokens._clientId,
|
||||||
|
authKind: "oauth",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// CodeBuddy (Tencent) - Browser OAuth Polling Flow
|
||||||
|
// 1. POST stateUrl → get { state, authUrl }
|
||||||
|
// 2. Open authUrl in browser
|
||||||
|
// 3. Poll tokenUrl with state until success (code 0) or timeout
|
||||||
|
codebuddy: {
|
||||||
|
config: CODEBUDDY_CONFIG,
|
||||||
|
flowType: "device_code",
|
||||||
|
requestDeviceCode: async (config) => {
|
||||||
|
const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": config.userAgent,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-Domain": "copilot.tencent.com",
|
||||||
|
"X-No-Authorization": "true",
|
||||||
|
"X-No-User-Id": "true",
|
||||||
|
"X-Product": "SaaS",
|
||||||
|
},
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) {
|
||||||
|
throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
device_code: data.data.state,
|
||||||
|
verification_uri: data.data.authUrl,
|
||||||
|
user_code: "",
|
||||||
|
interval: config.pollInterval / 1000,
|
||||||
|
_isCodeBuddy: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
pollToken: async (config, deviceCode) => {
|
||||||
|
const response = await fetch(config.tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": config.userAgent,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-Domain": "copilot.tencent.com",
|
||||||
|
"X-No-Authorization": "true",
|
||||||
|
"X-No-User-Id": "true",
|
||||||
|
"X-Product": "SaaS",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ state: deviceCode }),
|
||||||
|
});
|
||||||
|
if (!response.ok) return { ok: false, data: { error: "request_failed" } };
|
||||||
|
const data = await response.json();
|
||||||
|
// code 11217 = pending, code 0 = success
|
||||||
|
if (data.code === 0 && data.data?.accessToken) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
access_token: data.data.accessToken,
|
||||||
|
refresh_token: data.data.refreshToken || "",
|
||||||
|
token_type: data.data.tokenType || "Bearer",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } };
|
||||||
|
return { ok: false, data: { error: data.msg || "unknown_error" } };
|
||||||
|
},
|
||||||
|
mapTokens: (tokens) => ({
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
expiresIn: 86400,
|
||||||
|
providerSpecificData: {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -895,8 +1031,9 @@ export function getProviderNames() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate auth data for a provider
|
* Generate auth data for a provider
|
||||||
|
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
|
||||||
*/
|
*/
|
||||||
export function generateAuthData(providerName, redirectUri) {
|
export function generateAuthData(providerName, redirectUri, meta) {
|
||||||
const provider = getProvider(providerName);
|
const provider = getProvider(providerName);
|
||||||
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
||||||
|
|
||||||
@@ -905,9 +1042,9 @@ export function generateAuthData(providerName, redirectUri) {
|
|||||||
// Device code flow doesn't have auth URL upfront
|
// Device code flow doesn't have auth URL upfront
|
||||||
authUrl = null;
|
authUrl = null;
|
||||||
} else if (provider.flowType === "authorization_code_pkce") {
|
} else if (provider.flowType === "authorization_code_pkce") {
|
||||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
|
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge, meta || {});
|
||||||
} else {
|
} else {
|
||||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state);
|
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, undefined, meta || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -924,11 +1061,12 @@ export function generateAuthData(providerName, redirectUri) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange code for tokens
|
* Exchange code for tokens
|
||||||
|
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
|
||||||
*/
|
*/
|
||||||
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) {
|
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) {
|
||||||
const provider = getProvider(providerName);
|
const provider = getProvider(providerName);
|
||||||
|
|
||||||
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state);
|
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state, meta || {});
|
||||||
|
|
||||||
let extra = null;
|
let extra = null;
|
||||||
if (provider.postExchange) {
|
if (provider.postExchange) {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ async function getObservabilityConfig() {
|
|||||||
const { getSettings } = await import("@/lib/localDb");
|
const { getSettings } = await import("@/lib/localDb");
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
||||||
const enabled = typeof settings.observabilityEnabled === "boolean"
|
const enabled = typeof settings.enableObservability === "boolean"
|
||||||
? settings.observabilityEnabled
|
? settings.enableObservability
|
||||||
: envEnabled;
|
: envEnabled;
|
||||||
|
|
||||||
cachedConfig = {
|
cachedConfig = {
|
||||||
@@ -78,7 +78,7 @@ async function getObservabilityConfig() {
|
|||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
cachedConfig = {
|
cachedConfig = {
|
||||||
enabled: true,
|
enabled: false,
|
||||||
maxRecords: DEFAULT_MAX_RECORDS,
|
maxRecords: DEFAULT_MAX_RECORDS,
|
||||||
batchSize: DEFAULT_BATCH_SIZE,
|
batchSize: DEFAULT_BATCH_SIZE,
|
||||||
flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS,
|
flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS,
|
||||||
|
|||||||
@@ -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
|
* - Localhost: Auto callback via popup message
|
||||||
* - Remote: Manual paste callback URL
|
* - 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 [step, setStep] = useState("waiting"); // waiting | input | success | error
|
||||||
const [authData, setAuthData] = useState(null);
|
const [authData, setAuthData] = useState(null);
|
||||||
const [callbackUrl, setCallbackUrl] = useState("");
|
const [callbackUrl, setCallbackUrl] = useState("");
|
||||||
@@ -51,6 +51,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
redirectUri: authData.redirectUri,
|
redirectUri: authData.redirectUri,
|
||||||
codeVerifier: authData.codeVerifier,
|
codeVerifier: authData.codeVerifier,
|
||||||
state,
|
state,
|
||||||
|
...(oauthMeta ? { meta: oauthMeta } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Device code flow providers
|
// 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)) {
|
if (deviceCodeProviders.includes(provider)) {
|
||||||
setIsDeviceCode(true);
|
setIsDeviceCode(true);
|
||||||
setStep("waiting");
|
setStep("waiting");
|
||||||
@@ -153,18 +154,24 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
return;
|
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;
|
let redirectUri;
|
||||||
if (provider === "codex") {
|
if (provider === "codex") {
|
||||||
// Codex requires fixed port 1455
|
// Codex requires fixed port 1455
|
||||||
redirectUri = "http://localhost:1455/auth/callback";
|
redirectUri = "http://localhost:1455/auth/callback";
|
||||||
} else {
|
} 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");
|
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
|
||||||
redirectUri = `http://localhost:${port}/callback`;
|
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();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error);
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
|
||||||
@@ -462,9 +469,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
OAuthModal.propTypes = {
|
OAuthModal.propTypes = {
|
||||||
isOpen: PropTypes.bool.isRequired,
|
isOpen: PropTypes.bool.isRequired,
|
||||||
provider: PropTypes.string,
|
provider: PropTypes.string,
|
||||||
providerInfo: PropTypes.shape({
|
providerInfo: PropTypes.shape({ name: PropTypes.string }),
|
||||||
name: PropTypes.string,
|
|
||||||
}),
|
|
||||||
onSuccess: PropTypes.func,
|
onSuccess: PropTypes.func,
|
||||||
onClose: PropTypes.func.isRequired,
|
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 KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
|
||||||
export { default as CursorAuthModal } from "./CursorAuthModal";
|
export { default as CursorAuthModal } from "./CursorAuthModal";
|
||||||
export { default as IFlowCookieModal } from "./IFlowCookieModal";
|
export { default as IFlowCookieModal } from "./IFlowCookieModal";
|
||||||
|
export { default as GitLabAuthModal } from "./GitLabAuthModal";
|
||||||
export { default as SegmentedControl } from "./SegmentedControl";
|
export { default as SegmentedControl } from "./SegmentedControl";
|
||||||
export { default as Tooltip } from "./Tooltip";
|
export { default as Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
// Provider definitions
|
// Provider definitions
|
||||||
|
|
||||||
// Free Providers
|
// Free Providers (kiro first, iflow last)
|
||||||
export const FREE_PROVIDERS = {
|
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" },
|
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." },
|
"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
|
// 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" },
|
// "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" },
|
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" },
|
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 = {
|
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: { 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" },
|
"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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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" },
|
"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" },
|
"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)
|
// 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
|
// Auth methods
|
||||||
export const AUTH_METHODS = {
|
export const AUTH_METHODS = {
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user