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 && (
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
handleBatchTest("free")}
@@ -322,6 +323,16 @@ export default function ProvidersPage() {
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
/>
))}
+ {Object.entries(FREE_TIER_PROVIDERS).map(([key, info]) => (
+ handleToggleProvider(key, "apikey", active)}
+ />
+ ))}
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:
+
+
+
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"
+ >
+ lock_open
+
+
OAuth App
+
Use a GitLab OAuth application
+
+
+
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"
+ >
+ key
+
+
Personal Access Token
+
Use a GitLab PAT with api scope
+
+
+
+ >
+ )}
+
+ {/* 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}
}
+
+
+ Authorize
+
+ { setMode(null); setError(null); }} variant="ghost" fullWidth>
+ Back
+
+
+ >
+ )}
+
+ {/* 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}
}
+
+
+ Connect
+
+ { setMode(null); setError(null); }} variant="ghost" fullWidth>
+ Back
+
+
+ >
+ )}
+
+
+ );
+}
+
+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 [];
+ }
+}