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

View file

@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input, OAuthModal } from "@/shared/components";
const GITLAB_COM = "https://gitlab.com";
function getRedirectUri() {
if (typeof window === "undefined") return "http://localhost/callback";
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
return `http://localhost:${port}/callback`;
}
/**
* GitLab Duo Authentication Modal
* Supports two modes:
* - OAuth (PKCE): requires OAuth App Client ID (and optional Client Secret)
* - PAT: requires Personal Access Token
*/
export default function GitLabAuthModal({ isOpen, providerInfo, onSuccess, onClose }) {
const [mode, setMode] = useState(null); // null | "oauth" | "pat"
const [baseUrl, setBaseUrl] = useState(GITLAB_COM);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [showOAuth, setShowOAuth] = useState(false);
const [oauthMeta, setOauthMeta] = useState(null);
const reset = () => {
setMode(null);
setBaseUrl(GITLAB_COM);
setClientId("");
setClientSecret("");
setPat("");
setError(null);
setLoading(false);
setShowOAuth(false);
setOauthMeta(null);
};
const handleClose = () => {
reset();
onClose();
};
const handleOAuthStart = () => {
if (!clientId.trim()) {
setError("Client ID is required");
return;
}
setError(null);
setOauthMeta({ baseUrl: baseUrl.trim() || GITLAB_COM, clientId: clientId.trim(), clientSecret: clientSecret.trim() });
setShowOAuth(true);
};
const handlePATSubmit = async () => {
if (!pat.trim()) {
setError("Personal Access Token is required");
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch("/api/oauth/gitlab/pat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: pat.trim(), baseUrl: baseUrl.trim() || GITLAB_COM }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Authentication failed");
onSuccess?.();
handleClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
// Sub-modal for OAuth PKCE flow
if (showOAuth && oauthMeta) {
return (
<OAuthModal
isOpen
provider="gitlab"
providerInfo={providerInfo}
oauthMeta={oauthMeta}
onSuccess={() => { onSuccess?.(); handleClose(); }}
onClose={() => { setShowOAuth(false); setOauthMeta(null); }}
/>
);
}
return (
<Modal isOpen={isOpen} title="Connect GitLab Duo" onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{/* Mode selection */}
{!mode && (
<>
<p className="text-sm text-text-muted">
Choose how to authenticate with GitLab Duo:
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setMode("oauth")}
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<span className="material-symbols-outlined text-2xl text-primary">lock_open</span>
<div>
<p className="text-sm font-medium">OAuth App</p>
<p className="text-xs text-text-muted">Use a GitLab OAuth application</p>
</div>
</button>
<button
onClick={() => setMode("pat")}
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<span className="material-symbols-outlined text-2xl text-primary">key</span>
<div>
<p className="text-sm font-medium">Personal Access Token</p>
<p className="text-xs text-text-muted">Use a GitLab PAT with api scope</p>
</div>
</button>
</div>
</>
)}
{/* OAuth mode */}
{mode === "oauth" && (
<>
<p className="text-xs text-text-muted">
Create an OAuth app at{" "}
<a href={`${baseUrl.trim() || GITLAB_COM}/-/profile/applications`} target="_blank" rel="noreferrer" className="text-primary underline">
GitLab Applications
</a>{" "}
with redirect URI{" "}
<code className="bg-sidebar px-1 rounded text-xs">{getRedirectUri()}</code>
</p>
<Input label="GitLab Base URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={GITLAB_COM} />
<Input label="Client ID" value={clientId} onChange={(e) => setClientId(e.target.value)} placeholder="Your OAuth application client ID" />
<Input label="Client Secret (optional for PKCE)" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} placeholder="Leave empty for public PKCE app" />
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">
<Button onClick={handleOAuthStart} fullWidth disabled={!clientId.trim()}>
Authorize
</Button>
<Button onClick={() => { setMode(null); setError(null); }} variant="ghost" fullWidth>
Back
</Button>
</div>
</>
)}
{/* PAT mode */}
{mode === "pat" && (
<>
<p className="text-xs text-text-muted">
Create a PAT at{" "}
<a href={`${baseUrl.trim() || GITLAB_COM}/-/user_settings/personal_access_tokens`} target="_blank" rel="noreferrer" className="text-primary underline">
GitLab Access Tokens
</a>{" "}
with scopes: <code className="bg-sidebar px-1 rounded text-xs">api</code>,{" "}
<code className="bg-sidebar px-1 rounded text-xs">read_user</code>, and{" "}
<code className="bg-sidebar px-1 rounded text-xs">ai_features</code>.
</p>
<Input label="GitLab Base URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={GITLAB_COM} />
<Input label="Personal Access Token" value={pat} onChange={(e) => setPat(e.target.value)} placeholder="glpat-xxxxxxxxxxxxxxxxxxxx" type="password" />
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">
<Button onClick={handlePATSubmit} fullWidth disabled={!pat.trim() || loading} loading={loading}>
Connect
</Button>
<Button onClick={() => { setMode(null); setError(null); }} variant="ghost" fullWidth>
Back
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
GitLabAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
providerInfo: PropTypes.shape({ name: PropTypes.string }),
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View file

@ -10,7 +10,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
* - Localhost: Auto callback via popup message
* - Remote: Manual paste callback URL
*/
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose }) {
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, oauthMeta }) {
const [step, setStep] = useState("waiting"); // waiting | input | success | error
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
@ -51,6 +51,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
redirectUri: authData.redirectUri,
codeVerifier: authData.codeVerifier,
state,
...(oauthMeta ? { meta: oauthMeta } : {}),
}),
});
@ -132,7 +133,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(null);
// Device code flow providers
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode"];
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy"];
if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true);
setStep("waiting");
@ -153,18 +154,24 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return;
}
// Authorization code flow - always use localhost with current port (except Codex)
// Authorization code flow - build redirect URI (some providers require fixed ports)
let redirectUri;
if (provider === "codex") {
// Codex requires fixed port 1455
redirectUri = "http://localhost:1455/auth/callback";
} else {
// Always use localhost with current port for OAuth callback
// Use app's current port for OAuth callback
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
redirectUri = `http://localhost:${port}/callback`;
}
const res = await fetch(`/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
// Build authorize URL, optionally passing provider-specific metadata (e.g. gitlab clientId)
const authorizeUrl = new URL(`/api/oauth/${provider}/authorize`, window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
if (oauthMeta) {
Object.entries(oauthMeta).forEach(([k, v]) => { if (v) authorizeUrl.searchParams.set(k, v); });
}
const res = await fetch(authorizeUrl.toString());
const data = await res.json();
if (!res.ok) throw new Error(data.error);
@ -462,9 +469,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
OAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
provider: PropTypes.string,
providerInfo: PropTypes.shape({
name: PropTypes.string,
}),
providerInfo: PropTypes.shape({ name: PropTypes.string }),
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
/** Extra metadata passed to /authorize and /exchange (e.g. gitlab clientId/baseUrl) */
oauthMeta: PropTypes.object,
};

View file

@ -26,6 +26,7 @@ export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";

View file

@ -1,11 +1,21 @@
// Provider definitions
// Free Providers
// Free Providers (kiro first, iflow last)
export const FREE_PROVIDERS = {
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Google has tightened Gemini CLI abuse detection and restricted Pro models to paid accounts (Mar 25, 2026). Using this provider may violate ToS and risk account bans." },
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
};
// Free Tier Providers (has free access but may require account/API key)
export const FREE_TIER_PROVIDERS = {
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", passthroughModels: true, website: "https://openrouter.ai", notice: { text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After $10 credit: 1,000 req/day.", apiKeyUrl: "https://openrouter.ai/settings/keys" }, modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" } },
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim", notice: { text: "Free access for NVIDIA Developer Program members (prototyping & testing).", apiKeyUrl: "https://build.nvidia.com/settings/api-keys" } },
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com", notice: { text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", apiKeyUrl: "https://ollama.com/settings/api-keys" } },
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai", notice: { text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts" } },
};
// OAuth Providers
@ -18,11 +28,10 @@ export const OAUTH_PROVIDERS = {
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC" },
// opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC" },
};
export const APIKEY_PROVIDERS = {
openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#F97316", textIcon: "OR", passthroughModels: true, website: "https://openrouter.ai" },
glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL", website: "https://open.bigmodel.cn" },
"glm-cn": { id: "glm-cn", alias: "glm-cn", name: "GLM (China)", icon: "code", color: "#DC2626", textIcon: "GC", website: "https://open.bigmodel.cn" },
kimi: { id: "kimi", alias: "kimi", name: "Kimi", icon: "psychology", color: "#1E3A8A", textIcon: "KM", website: "https://kimi.moonshot.cn" },
@ -42,7 +51,6 @@ export const APIKEY_PROVIDERS = {
fireworks: { id: "fireworks", alias: "fireworks", name: "Fireworks AI", icon: "local_fire_department", color: "#7B2EF2", textIcon: "FW", website: "https://fireworks.ai" },
cerebras: { id: "cerebras", alias: "cerebras", name: "Cerebras", icon: "memory", color: "#FF4F00", textIcon: "CB", website: "https://www.cerebras.ai" },
cohere: { id: "cohere", alias: "cohere", name: "Cohere", icon: "hub", color: "#39594D", textIcon: "CO", website: "https://cohere.com" },
nvidia: { id: "nvidia", alias: "nvidia", name: "NVIDIA NIM", icon: "developer_board", color: "#76B900", textIcon: "NV", website: "https://developer.nvidia.com/nim" },
nebius: { id: "nebius", alias: "nebius", name: "Nebius AI", icon: "cloud", color: "#6C5CE7", textIcon: "NB", website: "https://nebius.com" },
siliconflow: { id: "siliconflow", alias: "siliconflow", name: "SiliconFlow", icon: "cloud_queue", color: "#5B6EF5", textIcon: "SF", website: "https://cloud.siliconflow.com" },
hyperbolic: { id: "hyperbolic", alias: "hyp", name: "Hyperbolic", icon: "bolt", color: "#00D4FF", textIcon: "HY", website: "https://hyperbolic.xyz" },
@ -50,9 +58,7 @@ export const APIKEY_PROVIDERS = {
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#ffffffff", textIcon: "CH", website: "https://chutes.ai" },
ollama: { id: "ollama", alias: "ollama", name: "Ollama Cloud", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
"ollama-local": { id: "ollama-local", alias: "ollama-local", name: "Ollama Local", icon: "cloud", color: "#ffffffff", textIcon: "OL", website: "https://ollama.com" },
vertex: { id: "vertex", alias: "vx", name: "Vertex AI", icon: "cloud", color: "#4285F4", textIcon: "VX", website: "https://cloud.google.com/vertex-ai" },
"vertex-partner": { id: "vertex-partner", alias: "vxp", name: "Vertex Partner", icon: "cloud", color: "#34A853", textIcon: "VP", website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models" },
};
@ -68,7 +74,7 @@ export function isAnthropicCompatibleProvider(providerId) {
}
// All providers (combined)
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...FREE_TIER_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
// Auth methods
export const AUTH_METHODS = {

View file

@ -0,0 +1,45 @@
// Fetch and cache suggested models for providers that expose a public models API
// Designed to be extensible: add new types in FILTERS below
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const cache = new Map(); // key: fetcher.url → { data, expiresAt }
const FILTERS = {
// Free models with context >= 200k tokens
"openrouter-free": (models) =>
models
.filter(
(m) =>
m.pricing?.prompt === "0" &&
m.pricing?.completion === "0" &&
m.context_length >= 200000
)
.map((m) => ({ id: m.id, name: m.name, contextLength: m.context_length }))
.sort((a, b) => b.contextLength - a.contextLength),
};
/**
* Fetch suggested models for a provider using its modelsFetcher config.
* Results are cached in-memory for CACHE_TTL_MS.
* @param {{ url: string, type: string }} fetcher
* @returns {Promise<Array<{ id: string, name: string, contextLength: number }>>}
*/
export async function fetchSuggestedModels(fetcher) {
if (!fetcher?.url || !fetcher?.type) return [];
const cached = cache.get(fetcher.url);
if (cached && Date.now() < cached.expiresAt) return cached.data;
try {
const res = await fetch(fetcher.url);
if (!res.ok) return [];
const json = await res.json();
const raw = json.data ?? json.models ?? json;
const filter = FILTERS[fetcher.type];
const data = filter ? filter(Array.isArray(raw) ? raw : []) : [];
cache.set(fetcher.url, { data, expiresAt: Date.now() + CACHE_TTL_MS });
return data;
} catch {
return [];
}
}