Files
openchamber/packages/web/server/lib/opencode/routes.js
T
Cursor AgentandSerhii Dziupin be87e25c7d feat: add custom/other OpenAI-compatible LLM providers
Allow Settings → Providers to define custom providers (id, name, base URL,
API key, models, headers) without code changes. Persist config via OpenCode
layers, store keys through auth.set, and keep web/VS Code parity.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-02 09:12:28 +00:00

665 lines
24 KiB
JavaScript

import { createProjectIdFromPath } from '../projects/project-id.js';
import fs from 'fs';
import os from 'os';
import path from 'path';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
crypto,
clientReloadDelayMs,
getOpenCodeResolutionSnapshot,
getOpenCodeUpgradeCapability,
formatSettingsResponse,
readSettingsFromDisk,
readSettingsFromDiskMigrated,
persistSettings,
sanitizeProjects,
validateDirectoryPath,
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
} = dependencies;
let authLibrary = null;
const pendingMcpAuthContextByState = new Map();
const PENDING_MCP_AUTH_TTL_MS = 30 * 60 * 1000;
const getAuthLibrary = async () => {
if (!authLibrary) {
authLibrary = await import('./auth.js');
}
return authLibrary;
};
const normalizePendingString = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed || null;
};
const readOpenCodeCurrentVersion = async () => {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText };
}
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
return { ok: true, currentVersion };
};
const parseVersionForComparison = (value) => {
const normalized = String(value || '').replace(/^v/, '').split('+')[0];
const prereleaseIndex = normalized.indexOf('-');
const core = prereleaseIndex >= 0 ? normalized.slice(0, prereleaseIndex) : normalized;
const parts = core.split('.').map((part) => {
const parsed = Number.parseInt(part || '0', 10);
return Number.isFinite(parsed) ? parsed : 0;
});
return { parts, prerelease: prereleaseIndex >= 0 };
};
const compareVersions = (left, right) => {
const a = parseVersionForComparison(left);
const b = parseVersionForComparison(right);
const length = Math.max(a.parts.length, b.parts.length);
for (let index = 0; index < length; index += 1) {
const diff = (a.parts[index] || 0) - (b.parts[index] || 0);
if (diff !== 0) return diff;
}
if (a.prerelease !== b.prerelease) return a.prerelease ? -1 : 1;
return 0;
};
const fetchLatestOpenCodeVersionFromGithub = async () => {
const response = await fetch('https://api.github.com/repos/anomalyco/opencode/releases/latest', {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`OpenCode releases responded with ${response.status}`);
}
const payload = await response.json();
const tag = typeof payload?.tag_name === 'string' ? payload.tag_name.trim() : '';
return tag.replace(/^v/, '');
};
const fetchLatestOpenCodeVersionFromNpm = async () => {
const response = await fetch('https://registry.npmjs.org/opencode-ai/latest', {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`OpenCode npm registry responded with ${response.status}`);
}
const payload = await response.json();
return typeof payload?.version === 'string' ? payload.version.trim().replace(/^v/, '') : '';
};
const fetchLatestOpenCodeVersion = async () => {
const results = await Promise.allSettled([
fetchLatestOpenCodeVersionFromNpm(),
fetchLatestOpenCodeVersionFromGithub(),
]);
const versions = results
.filter((result) => result.status === 'fulfilled' && result.value)
.map((result) => result.value);
if (versions.length === 0) {
const failure = results.find((result) => result.status === 'rejected');
throw failure?.reason instanceof Error ? failure.reason : new Error('Failed to resolve latest OpenCode version');
}
return versions.sort((left, right) => compareVersions(right, left))[0];
};
const pruneExpiredPendingMcpAuthContexts = () => {
const now = Date.now();
for (const [state, entry] of pendingMcpAuthContextByState.entries()) {
if (!entry || typeof entry.expiresAt !== 'number' || entry.expiresAt <= now) {
pendingMcpAuthContextByState.delete(state);
}
}
};
app.get('/api/config/settings', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
res.json(formatSettingsResponse(settings));
} catch (error) {
console.error('Failed to read settings:', error);
res.status(500).json({ error: 'Failed to read settings' });
}
});
app.get('/api/config/opencode-resolution', async (_req, res) => {
try {
const settings = await readSettingsFromDiskMigrated();
const resolution = await getOpenCodeResolutionSnapshot(settings);
res.json(resolution);
} catch (error) {
console.error('Failed to resolve OpenCode binary:', error);
res.status(500).json({ error: 'Failed to resolve OpenCode binary' });
}
});
let openCodeUpgradePromise = null;
app.post('/api/opencode/upgrade', async (req, res) => {
try {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
return res.status(409).json({
success: false,
code: capability.reason === 'bundled'
? 'OPENCODE_UPGRADE_MANAGED_BY_OPENCHAMBER'
: 'OPENCODE_UPGRADE_UNSUPPORTED',
error: capability.reason === 'bundled'
? 'OpenCode is bundled with OpenChamber Desktop and updates with the app.'
: 'This OpenCode runtime cannot be upgraded by OpenChamber.',
});
}
if (openCodeUpgradePromise) {
return res.status(409).json({
success: false,
code: 'OPENCODE_UPGRADE_IN_PROGRESS',
error: 'An OpenCode upgrade is already in progress.',
});
}
const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
? req.body.target.trim()
: undefined;
const upgradeOperation = (async () => {
const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...getOpenCodeAuthHeaders(),
},
body: JSON.stringify(target ? { target } : {}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
return {
status: response.status,
body: {
success: false,
error: payload?.error || response.statusText || 'Failed to upgrade OpenCode',
},
};
}
try {
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
} catch (restartError) {
return {
status: 500,
body: {
success: false,
upgraded: true,
error: restartError instanceof Error
? `OpenCode upgraded, but restart failed: ${restartError.message}`
: 'OpenCode upgraded, but restart failed',
},
};
}
return {
status: 200,
body: { ...(payload ?? { success: true }), restarted: true },
};
})();
openCodeUpgradePromise = upgradeOperation;
try {
const result = await upgradeOperation;
return res.status(result.status).json(result.body);
} finally {
if (openCodeUpgradePromise === upgradeOperation) {
openCodeUpgradePromise = null;
}
}
} catch (error) {
console.error('Failed to upgrade OpenCode:', error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to upgrade OpenCode',
});
}
});
app.get('/api/opencode/upgrade-status', async (_req, res) => {
try {
const capability = getOpenCodeUpgradeCapability();
if (!capability.supported) {
const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null }));
return res.json({
available: false,
currentVersion: current.ok ? current.currentVersion : null,
latestVersion: null,
upgrade: capability,
});
}
const [healthResponse, latestVersion] = await Promise.all([
fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
}),
fetchLatestOpenCodeVersion(),
]);
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return res.status(healthResponse.status).json({
available: null,
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
});
}
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
if (!currentVersion || !latestVersion) {
return res.json({ available: null, currentVersion, latestVersion: latestVersion || null });
}
const available = compareVersions(latestVersion, currentVersion) > 0;
return res.json({
available,
currentVersion,
latestVersion,
upgrade: capability,
});
} catch (error) {
return res.status(500).json({
available: null,
error: error instanceof Error ? error.message : 'Failed to check OpenCode upgrade status',
});
}
});
app.get('/api/opencode/health', async (_req, res) => {
try {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return res.status(healthResponse.status).json({
healthy: false,
error: health?.error || healthResponse.statusText || 'OpenCode health check failed',
});
}
return res.json({ healthy: health?.healthy === true });
} catch (error) {
return res.status(503).json({
healthy: false,
error: error instanceof Error ? error.message : 'OpenCode health check failed',
});
}
});
app.get('/api/opencode/version', async (_req, res) => {
try {
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
method: 'GET',
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
});
const health = await healthResponse.json().catch(() => null);
if (!healthResponse.ok) {
return res.status(healthResponse.status).json({
version: null,
error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version',
});
}
const version = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
return res.json({ version });
} catch (error) {
return res.status(500).json({
version: null,
error: error instanceof Error ? error.message : 'Failed to read OpenCode version',
});
}
});
app.put('/api/config/settings', async (req, res) => {
try {
const updated = await persistSettings(req.body ?? {});
res.json(updated);
} catch (error) {
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
console.error('[API:PUT /api/config/settings] Error stack:', error.stack);
res.status(500).json({ error: 'Failed to save settings' });
}
});
app.post('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(req.body?.state);
if (!state) {
return res.json({ success: true, context: null });
}
const name = normalizePendingString(req.body?.name);
if (!name) {
return res.status(400).json({ error: 'MCP server name is required' });
}
const entry = {
name,
directory: normalizePendingString(req.body?.directory),
expiresAt: Date.now() + PENDING_MCP_AUTH_TTL_MS,
};
pendingMcpAuthContextByState.set(state, entry);
return res.json({
success: true,
context: {
name: entry.name,
directory: entry.directory,
},
});
} catch (error) {
console.error('Failed to store pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to store pending MCP auth context' });
}
});
app.get('/api/mcp/auth/pending', async (req, res) => {
try {
pruneExpiredPendingMcpAuthContexts();
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json(null);
}
const pendingMcpAuthContext = pendingMcpAuthContextByState.get(state) ?? null;
if (!pendingMcpAuthContext) {
return res.status(404).json({ error: 'No pending MCP auth context' });
}
return res.json(pendingMcpAuthContext);
} catch (error) {
console.error('Failed to read pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to read pending MCP auth context' });
}
});
app.delete('/api/mcp/auth/pending', async (req, res) => {
try {
const state = normalizePendingString(Array.isArray(req.query?.state) ? req.query.state[0] : req.query?.state);
if (!state) {
return res.json({ success: true });
}
pendingMcpAuthContextByState.delete(state);
return res.json({ success: true });
} catch (error) {
console.error('Failed to clear pending MCP auth context:', error);
return res.status(500).json({ error: error.message || 'Failed to clear pending MCP auth context' });
}
});
app.get('/api/provider/:providerId/source', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
} else if (requestedDirectory) {
return res.status(400).json({ error: resolved.error });
}
const sources = getProviderSources(providerId, directory);
const { getProviderAuth } = await getAuthLibrary();
const auth = getProviderAuth(providerId);
sources.sources.auth.exists = Boolean(auth);
return res.json({
providerId,
sources: sources.sources,
});
} catch (error) {
console.error('Failed to get provider sources:', error);
return res.status(500).json({ error: error.message || 'Failed to get provider sources' });
}
});
app.put('/api/provider', async (req, res) => {
try {
const providerID = typeof req.body?.providerID === 'string'
? req.body.providerID.trim()
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
const config = req.body?.config;
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
if (!providerID) {
return res.status(400).json({ error: 'Provider ID is required' });
}
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return res.status(400).json({ error: 'Provider config is required' });
}
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
return res.status(400).json({ error: 'Invalid scope' });
}
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
const result = upsertProviderConfig(providerID, config, directory, scope);
await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`);
return res.json({
success: true,
providerId: result.providerId,
path: result.path,
config: result.config,
requiresReload: true,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
console.error('Failed to upsert provider config:', error);
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
}
});
app.delete('/api/provider/:providerId/auth', async (req, res) => {
try {
const { providerId } = req.params;
if (!providerId) {
return res.status(400).json({ error: 'Provider ID is required' });
}
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
const queryDirectory = Array.isArray(req.query?.directory)
? req.query.directory[0]
: req.query?.directory;
const requestedDirectory = headerDirectory || queryDirectory || null;
let directory = null;
if (scope === 'project' || requestedDirectory) {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({ error: resolved.error });
}
directory = resolved.directory;
} else {
const resolved = await resolveProjectDirectory(req);
if (resolved.directory) {
directory = resolved.directory;
}
}
let removed = false;
if (scope === 'auth') {
const { removeProviderAuth } = await getAuthLibrary();
removed = removeProviderAuth(providerId);
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
removed = removeProviderConfig(providerId, directory, scope);
} else if (scope === 'all') {
const { removeProviderAuth } = await getAuthLibrary();
const authRemoved = removeProviderAuth(providerId);
const userRemoved = removeProviderConfig(providerId, directory, 'user');
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
} else {
return res.status(400).json({ error: 'Invalid scope' });
}
if (removed) {
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
}
return res.json({
success: true,
removed,
requiresReload: removed,
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
reloadDelayMs: removed ? clientReloadDelayMs : undefined,
});
} catch (error) {
console.error('Failed to disconnect provider:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
}
});
app.post('/api/opencode/directory', async (req, res) => {
try {
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
if (!requestedPath) {
return res.status(400).json({ error: 'Path is required' });
}
const validated = await validateDirectoryPath(requestedPath);
if (!validated.ok) {
return res.status(400).json({ error: validated.error });
}
const resolvedPath = validated.directory;
const currentSettings = await readSettingsFromDisk();
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
const nextProjects = existing
? existingProjects
: [
...existingProjects,
{
id: createProjectIdFromPath(resolvedPath),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
},
];
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
const updated = await persistSettings({
projects: nextProjects,
activeProjectId,
lastDirectory: resolvedPath,
});
return res.json({
success: true,
restarted: false,
path: resolvedPath,
settings: updated,
});
} catch (error) {
console.error('Failed to update OpenCode working directory:', error);
return res.status(500).json({ error: error.message || 'Failed to update working directory' });
}
});
// Behavior / Global AGENTS.md endpoints
const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
app.get('/api/behavior/agents-md', async (_req, res) => {
try {
try {
await fs.promises.access(AGENTS_MD_PATH);
} catch {
return res.json({ content: '', exists: false });
}
const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8');
return res.json({ content, exists: true });
} catch (error) {
console.error('Failed to read AGENTS.md:', error);
return res.status(500).json({ error: 'Failed to read AGENTS.md' });
}
});
app.put('/api/behavior/agents-md', async (req, res) => {
try {
const content = typeof req.body?.content === 'string' ? req.body.content : '';
if (content.length > MAX_BEHAVIOR_PROMPT_SIZE) {
return res.status(413).json({ error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` });
}
// Ensure parent directory exists
const parentDir = path.dirname(AGENTS_MD_PATH);
try {
await fs.promises.access(parentDir);
} catch {
await fs.promises.mkdir(parentDir, { recursive: true });
}
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
// Refresh OpenCode so it picks up the new AGENTS.md without a full restart
try {
await refreshOpenCodeAfterConfigChange('global behavior (AGENTS.md) updated');
} catch {
// Non-fatal: file was written successfully
}
return res.json({ success: true });
} catch (error) {
console.error('Failed to write AGENTS.md:', error);
return res.status(500).json({ error: error.message || 'Failed to write AGENTS.md' });
}
});
};