feat: add GitLab Duo and CodeBuddy support, update observability settings

This commit is contained in:
decolua
2026-03-30 11:28:07 +07:00
parent 11e6004fcb
commit abbf8ec86f
21 changed files with 779 additions and 141 deletions
@@ -187,7 +187,7 @@ export default function CLIToolsPageClient({ machineId }) {
return (
<div className="flex flex-col gap-6">
{!hasActiveProviders && (
{/* {!hasActiveProviders && (
<Card className="border-yellow-500/50 bg-yellow-500/5">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
@@ -197,7 +197,7 @@ export default function CLIToolsPageClient({ machineId }) {
</div>
</div>
</Card>
)}
)} */}
<div className="flex flex-col gap-4">
{regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
</div>
@@ -243,13 +243,13 @@ export default function ProfilePage() {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ observabilityEnabled: enabled }),
body: JSON.stringify({ enableObservability: enabled }),
});
if (res.ok) {
setSettings(prev => ({ ...prev, observabilityEnabled: enabled }));
setSettings(prev => ({ ...prev, enableObservability: enabled }));
}
} 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 (
<div className="max-w-2xl mx-auto">
@@ -5,10 +5,11 @@ import PropTypes from "prop-types";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, Toggle, Select } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
export default function ProviderDetailPage() {
const params = useParams();
@@ -36,6 +37,7 @@ export default function ProviderDetailPage() {
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const [providerStrategy, setProviderStrategy] = useState(null); // null = use global, "round-robin" = override
const [providerStickyLimit, setProviderStickyLimit] = useState("");
const [suggestedModels, setSuggestedModels] = useState([]);
const { copied, copy } = useCopyToClipboard();
const providerInfo = providerNode
@@ -48,7 +50,7 @@ export default function ProviderDetailPage() {
baseUrl: providerNode.baseUrl,
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 models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
@@ -189,6 +191,13 @@ export default function ProviderDetailPage() {
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 fullModel = `${providerAliasOverride}/${modelId}`;
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)
const customModels = Object.entries(modelAliases)
.filter(([alias, fullModel]) => {
@@ -547,6 +544,8 @@ export default function ProviderDetailPage() {
if (!fullModel.startsWith(prefix)) return false;
const modelId = fullModel.slice(prefix.length);
// 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;
})
.map(([alias, fullModel]) => ({
@@ -606,6 +605,36 @@ export default function ProviderDetailPage() {
<span className="material-symbols-outlined text-sm">add</span>
Add Model
</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>
);
};
@@ -689,6 +718,23 @@ export default function ProviderDetailPage() {
</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 && (
<Card>
<div className="flex items-center justify-between mb-4">
@@ -824,7 +870,7 @@ export default function ProviderDetailPage() {
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
{"Available Models"}
</h2>
</div>
{!!modelsTestError && (
@@ -849,6 +895,13 @@ export default function ProviderDetailPage() {
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "gitlab" ? (
<GitLabAuthModal
isOpen={showOAuthModal}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}
@@ -891,13 +944,17 @@ export default function ProviderDetailPage() {
isAnthropic={isAnthropicCompatible}
/>
)}
{!isCompatible && !providerInfo?.passthroughModels && (
{!isCompatible && (
<AddCustomModelModal
isOpen={showAddCustomModel}
providerAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
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);
}}
onClose={() => setShowAddCustomModel(false)}
@@ -931,26 +988,34 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCusto
</span>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
{onTest && (
<div className="relative group/btn">
<button
onClick={onTest}
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"}`}
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
</span>
</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
onClick={onTest}
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"}`}
title="Test model"
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
)}
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy model"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
<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>
</button>
</div>
{isCustom && (
<button
onClick={onDeleteAlias}
@@ -1103,26 +1168,34 @@ function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias
<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>
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy model"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
{onTest && (
<div className="relative group/btn">
<button
onClick={onTest}
disabled={isTesting}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
title="Test model"
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</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 && (
<div className="relative group/btn">
<button
onClick={onTest}
disabled={isTesting}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
</span>
</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>
@@ -16,6 +16,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import {
FREE_PROVIDERS,
FREE_TIER_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
@@ -286,11 +287,11 @@ export default function ProvidersPage() {
</div>
</div>
{/* Free Providers */}
{/* Free & Free Tier Providers */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
Free Providers
Free &amp; Free Tier Providers
</h2>
<button
onClick={() => handleBatchTest("free")}
@@ -322,6 +323,16 @@ export default function ProvidersPage() {
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>
+10 -6
View File
@@ -22,7 +22,11 @@ export async function GET(request, { params }) {
if (action === "authorize") {
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);
}
@@ -35,7 +39,7 @@ export async function GET(request, { params }) {
const authData = generateAuthData(provider, null);
// 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;
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider);
@@ -70,7 +74,7 @@ export async function POST(request, { params }) {
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state } = body;
const { code, redirectUri, codeVerifier, state, meta } = body;
// Cline uses authorization_code without PKCE
const noPkceExchangeProviders = ["cline"];
@@ -78,8 +82,8 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Exchange code for tokens
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state);
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl)
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta);
// Save to database
const connection = await createProviderConnection({
@@ -111,7 +115,7 @@ export async function POST(request, { params }) {
}
// Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi-coding", "kilocode"];
const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy"];
let result;
if (noPkceProviders.includes(provider)) {
result = await pollForToken(provider, deviceCode);
+62
View File
@@ -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 ",
},
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) {