diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index f0ed696..ca4a3ee 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -212,7 +212,7 @@ export const PROVIDER_MODELS = { { id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" }, ], openrouter: [ - { id: "auto", name: "Auto (Best Available)" }, + // { id: "openrouter/free", name: "Free Models (Auto)" }, ], glm: [ { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 1779bd2..b1c7831 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -316,4 +316,14 @@ export const PROVIDERS = { baseUrl: "https://aiplatform.googleapis.com", 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", + }, }; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index eb3e19d..8b951cc 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -66,6 +66,11 @@ export class DefaultExecutor extends BaseExecutor { if (!headers["anthropic-version"]) { 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") { headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; if (credentials.providerSpecificData?.orgId) { diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 4501a33..636da6c 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -139,7 +139,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) function: { name, description: String(tool.description || ""), - parameters: tool.parameters, + parameters: normalizeToolParameters(tool.parameters), strict: tool.strict } }; @@ -158,6 +158,15 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) 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 */ @@ -226,7 +235,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) result.input.push({ type: "function_call", call_id: clampCallId(tc.id), - name: tc.function?.name || "", + name: tc.function?.name || "_unknown", arguments: tc.function?.arguments || "{}" }); } @@ -260,7 +269,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) type: "function", name: tool.function.name, description: String(tool.function.description || ""), - parameters: tool.function.parameters, + parameters: normalizeToolParameters(tool.function.parameters), strict: tool.function.strict }; } diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png index 9215a38..f80a72e 100644 Binary files a/public/providers/nvidia.png and b/public/providers/nvidia.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 5c432d7..5200779 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -187,7 +187,7 @@ export default function CLIToolsPageClient({ machineId }) { return (
- {!hasActiveProviders && ( + {/* {!hasActiveProviders && (
warning @@ -197,7 +197,7 @@ export default function CLIToolsPageClient({ machineId }) {
- )} + )} */}
{regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 9d08b80..2731027 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -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 (
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 7cb9eff..56aaa12 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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 ( - - ); - } // 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() { add Add Model + + {/* 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 ( +
+

Suggested free models (≥200k context):

+
+ {notAdded.map((m) => ( + + ))} +
+
+ ); + })()}
); }; @@ -689,6 +718,23 @@ export default function ProviderDetailPage() { )} + {providerInfo.notice && !providerInfo.deprecated && ( +
+ info +

{providerInfo.notice.text}

+ {providerInfo.notice.apiKeyUrl && ( + + Get API Key → + + )} +
+ )} + {isCompatible && providerNode && (
@@ -824,7 +870,7 @@ export default function ProviderDetailPage() {

- {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} + {"Available Models"}

{!!modelsTestError && ( @@ -849,6 +895,13 @@ export default function ProviderDetailPage() { onSuccess={handleOAuthSuccess} onClose={() => setShowOAuthModal(false)} /> + ) : providerId === "gitlab" ? ( + setShowOAuthModal(false)} + /> ) : ( )} - {!isCompatible && !providerInfo?.passthroughModels && ( + {!isCompatible && ( { - 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 {fullModel} {onTest && ( +
+ + + {isTesting ? "Testing..." : "Test"} + +
+ )} +
- )} - +
{isCustom && ( - {onTest && ( +
+ + {copied === `model-${modelId}` ? "Copied!" : "Copy"} + +
+ {onTest && ( +
+ + + {isTesting ? "Testing..." : "Test"} + +
)}
diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index f23edea..2bafb3c 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -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() { - {/* Free Providers */} + {/* Free & Free Tier Providers */}

- Free Providers + Free & Free Tier Providers

diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 4497684..660f803 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -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); diff --git a/src/app/api/oauth/gitlab/pat/route.js b/src/app/api/oauth/gitlab/pat/route.js new file mode 100644 index 0000000..286ddc2 --- /dev/null +++ b/src/app/api/oauth/gitlab/pat/route.js @@ -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 }); + } +} diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 08f97ed..9b42262 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -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) { diff --git a/src/lib/localDb.js b/src/lib/localDb.js index de44066..6756976 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -40,6 +40,11 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) { 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 const defaultData = { providerConnections: [], @@ -58,7 +63,7 @@ const defaultData = { comboStrategy: "fallback", comboStrategies: {}, requireLogin: true, - observabilityEnabled: true, + enableObservability: false, observabilityMaxRecords: 1000, observabilityBatchSize: 20, observabilityFlushIntervalMs: 5000, @@ -88,7 +93,7 @@ function cloneDefaultData() { comboStrategy: "fallback", comboStrategies: {}, requireLogin: true, - observabilityEnabled: true, + enableObservability: false, observabilityMaxRecords: 1000, observabilityBatchSize: 20, observabilityFlushIntervalMs: 5000, @@ -162,6 +167,19 @@ function ensureDbShape(data) { // Singleton instance 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 const LOCK_OPTIONS = { 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) { if (isCloud) { @@ -214,9 +233,10 @@ async function safeWrite(db) { let release = null; try { - // Acquire lock before writing release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); await db.write(); + // Invalidate cache immediately after a successful write + dbCache.ts = 0; } catch (error) { if (error.code === "ELOCKED") { 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() { if (isCloud) { - // Return in-memory DB for Workers if (!dbInstance) { const data = cloneDefaultData(); dbInstance = new Low({ read: async () => {}, write: async () => {} }, data); @@ -248,37 +271,57 @@ export async function getDb() { return dbInstance; } - if (!dbInstance) { - const adapter = new JSONFile(DB_FILE); - dbInstance = new Low(adapter, cloneDefaultData()); + // 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; } - // Always read latest disk state to avoid stale singleton data across route workers. - try { - await safeRead(dbInstance); - } catch (error) { - if (error instanceof SyntaxError) { - console.warn('[DB] Corrupt JSON detected, resetting to defaults...'); + // 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) { + const adapter = new JSONFile(DB_FILE); + dbInstance = new Low(adapter, cloneDefaultData()); + } + + try { + await safeRead(dbInstance); + } catch (error) { + if (error instanceof SyntaxError) { + console.warn("[DB] Corrupt JSON detected, resetting to defaults..."); + dbInstance.data = cloneDefaultData(); + await safeWrite(dbInstance); + } else { + throw error; + } + } + + // Initialize/migrate missing keys for older DB schema versions + if (!dbInstance.data) { dbInstance.data = cloneDefaultData(); await safeWrite(dbInstance); } else { - throw error; + const { data, changed } = ensureDbShape(dbInstance.data); + dbInstance.data = data; + if (changed) await safeWrite(dbInstance); } - } - // Initialize/migrate missing keys for older DB schema versions. - if (!dbInstance.data) { - dbInstance.data = cloneDefaultData(); - await safeWrite(dbInstance); - } else { - const { data, changed } = ensureDbShape(dbInstance.data); - dbInstance.data = data; - if (changed) { - await safeWrite(dbInstance); - } - } + // Update cache after successful read + dbCache.data = dbInstance.data; + dbCache.ts = Date.now(); - return dbInstance; + return dbInstance; + }); } // ============ Provider Connections ============ @@ -608,10 +651,7 @@ export async function createProviderConnection(data) { } db.data.providerConnections.push(connection); - await safeWrite(db); - - // Reorder to ensure consistency - await reorderProviderConnections(data.provider); + await reorderProviderConnections(data.provider, db); return connection; } @@ -633,11 +673,11 @@ export async function updateProviderConnection(id, data) { updatedAt: new Date().toISOString(), }; - await safeWrite(db); - - // Reorder if priority was changed + // Reorder if priority was changed, reuse same db instance to avoid double-read if (data.priority !== undefined) { - await reorderProviderConnections(providerId); + await reorderProviderConnections(providerId, db); + } else { + await safeWrite(db); } return db.data.providerConnections[index]; @@ -655,37 +695,35 @@ export async function deleteProviderConnection(id) { const providerId = db.data.providerConnections[index].provider; db.data.providerConnections.splice(index, 1); - await safeWrite(db); - - // Reorder to fill gaps - await reorderProviderConnections(providerId); + // Reorder to fill gaps, reuse same db instance to avoid double-read + await reorderProviderConnections(providerId, db); 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) { - const db = await getDb(); - if (!db.data.providerConnections) return; +export async function reorderProviderConnections(providerId, db) { + const instance = db || (await getDb()); + if (!instance.data.providerConnections) return; - const providerConnections = db.data.providerConnections + const providerConnections = instance.data.providerConnections .filter(c => c.provider === providerId) .sort((a, b) => { - // Sort by priority first const pDiff = (a.priority || 0) - (b.priority || 0); if (pDiff !== 0) return pDiff; // Use updatedAt as tie-breaker (newer first) return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0); }); - // Re-assign sequential priorities providerConnections.forEach((conn, index) => { conn.priority = index + 1; }); - await safeWrite(db); + await safeWrite(instance); } // ============ Model Aliases ============ diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index eba4881..477e77b 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -216,6 +216,31 @@ export const CLINE_CONFIG = { 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) export const OAUTH_TIMEOUT = 300000; @@ -234,4 +259,6 @@ export const PROVIDERS = { KIMI_CODING: "kimi-coding", KILOCODE: "kilocode", CLINE: "cline", + GITLAB: "gitlab", + CODEBUDDY: "codebuddy", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index ebd579e..01a5604 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -20,6 +20,8 @@ import { KIMI_CODING_CONFIG, KILOCODE_CONFIG, CLINE_CONFIG, + GITLAB_CONFIG, + CODEBUDDY_CONFIG, } from "./constants/oauth"; // Provider configurations @@ -873,6 +875,140 @@ const PROVIDERS = { 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 + * @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 { codeVerifier, codeChallenge, state } = generatePKCE(); @@ -905,9 +1042,9 @@ export function generateAuthData(providerName, redirectUri) { // Device code flow doesn't have auth URL upfront authUrl = null; } 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 { - authUrl = provider.buildAuthUrl(provider.config, redirectUri, state); + authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, undefined, meta || {}); } return { @@ -924,11 +1061,12 @@ export function generateAuthData(providerName, redirectUri) { /** * 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 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; if (provider.postExchange) { diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js index 25fc611..da03c8e 100644 --- a/src/lib/requestDetailsDb.js +++ b/src/lib/requestDetailsDb.js @@ -65,8 +65,8 @@ async function getObservabilityConfig() { const { getSettings } = await import("@/lib/localDb"); const settings = await getSettings(); const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false"; - const enabled = typeof settings.observabilityEnabled === "boolean" - ? settings.observabilityEnabled + const enabled = typeof settings.enableObservability === "boolean" + ? settings.enableObservability : envEnabled; cachedConfig = { @@ -78,7 +78,7 @@ async function getObservabilityConfig() { }; } catch { cachedConfig = { - enabled: true, + enabled: false, maxRecords: DEFAULT_MAX_RECORDS, batchSize: DEFAULT_BATCH_SIZE, flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS, diff --git a/src/shared/components/GitLabAuthModal.js b/src/shared/components/GitLabAuthModal.js new file mode 100644 index 0000000..fa05a0c --- /dev/null +++ b/src/shared/components/GitLabAuthModal.js @@ -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 ( + { onSuccess?.(); handleClose(); }} + onClose={() => { setShowOAuth(false); setOauthMeta(null); }} + /> + ); + } + + return ( + +
+ {/* Mode selection */} + {!mode && ( + <> +

+ Choose how to authenticate with GitLab Duo: +

+
+ + +
+ + )} + + {/* OAuth mode */} + {mode === "oauth" && ( + <> +

+ Create an OAuth app at{" "} + + GitLab Applications + {" "} + with redirect URI{" "} + {getRedirectUri()} +

+ setBaseUrl(e.target.value)} placeholder={GITLAB_COM} /> + setClientId(e.target.value)} placeholder="Your OAuth application client ID" /> + setClientSecret(e.target.value)} placeholder="Leave empty for public PKCE app" /> + {error &&

{error}

} +
+ + +
+ + )} + + {/* PAT mode */} + {mode === "pat" && ( + <> +

+ Create a PAT at{" "} + + GitLab Access Tokens + {" "} + with scopes: api,{" "} + read_user, and{" "} + ai_features. +

+ setBaseUrl(e.target.value)} placeholder={GITLAB_COM} /> + setPat(e.target.value)} placeholder="glpat-xxxxxxxxxxxxxxxxxxxx" type="password" /> + {error &&

{error}

} +
+ + +
+ + )} +
+
+ ); +} + +GitLabAuthModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + providerInfo: PropTypes.shape({ name: PropTypes.string }), + onSuccess: PropTypes.func, + onClose: PropTypes.func.isRequired, +}; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index c25c501..475b766 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -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, }; diff --git a/src/shared/components/index.js b/src/shared/components/index.js index b204454..f8f3e50 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -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"; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index a6c92dd..9436d3f 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -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 = { diff --git a/src/shared/utils/providerModelsFetcher.js b/src/shared/utils/providerModelsFetcher.js new file mode 100644 index 0000000..849a934 --- /dev/null +++ b/src/shared/utils/providerModelsFetcher.js @@ -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>} + */ +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 []; + } +}