Files
Neon-Desk/lib/server/openclaw.ts

168 lines
4.5 KiB
TypeScript

type ChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
type OpenClawAuthMode = 'bearer' | 'basic' | 'none';
type OpenClawConfig = {
baseUrl?: string;
apiKey?: string;
model: string;
authMode: OpenClawAuthMode;
basicAuthUsername?: string;
basicAuthPassword?: string;
apiKeyHeader?: string;
};
function envValue(name: string) {
const value = process.env[name];
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
const DEFAULT_MODEL = 'zeroclaw';
const DEFAULT_AUTH_MODE: OpenClawAuthMode = 'bearer';
function parseAuthMode(value: string | undefined): OpenClawAuthMode {
const normalized = value?.trim().toLowerCase();
if (normalized === 'basic' || normalized === 'none') {
return normalized;
}
return DEFAULT_AUTH_MODE;
}
function hasSupportedProtocol(url: string) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function buildCompletionsUrl(baseUrl: string) {
if (!hasSupportedProtocol(baseUrl)) {
return undefined;
}
const withSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL('v1/chat/completions', withSlash).toString();
}
function hasRequiredAuth(config: OpenClawConfig) {
if (config.authMode === 'none') {
return true;
}
if (config.authMode === 'basic') {
return Boolean(config.basicAuthUsername && config.basicAuthPassword);
}
return Boolean(config.apiKey);
}
function buildAuthHeaders(config: OpenClawConfig) {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (config.authMode === 'basic' && config.basicAuthUsername && config.basicAuthPassword) {
const credentials = Buffer
.from(`${config.basicAuthUsername}:${config.basicAuthPassword}`, 'utf8')
.toString('base64');
headers.Authorization = `Basic ${credentials}`;
} else if (config.authMode === 'bearer' && config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
if (config.apiKey && config.apiKeyHeader) {
headers[config.apiKeyHeader] = config.apiKey;
}
return headers;
}
function fallbackResponse(prompt: string) {
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
return [
'OpenClaw fallback mode is active (configuration is missing or invalid).',
'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.',
'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.',
`Context digest: ${clipped}`
].join('\n\n');
}
export function getOpenClawConfig() {
return {
baseUrl: envValue('OPENCLAW_BASE_URL'),
apiKey: envValue('OPENCLAW_API_KEY'),
model: envValue('OPENCLAW_MODEL') ?? DEFAULT_MODEL,
authMode: parseAuthMode(envValue('OPENCLAW_AUTH_MODE')),
basicAuthUsername: envValue('OPENCLAW_BASIC_AUTH_USERNAME'),
basicAuthPassword: envValue('OPENCLAW_BASIC_AUTH_PASSWORD'),
apiKeyHeader: envValue('OPENCLAW_API_KEY_HEADER')
} satisfies OpenClawConfig;
}
export function isOpenClawConfigured() {
const config = getOpenClawConfig();
return Boolean(config.baseUrl && hasSupportedProtocol(config.baseUrl) && hasRequiredAuth(config));
}
export async function runOpenClawAnalysis(prompt: string, systemPrompt?: string) {
const config = getOpenClawConfig();
const endpoint = config.baseUrl ? buildCompletionsUrl(config.baseUrl) : undefined;
if (!endpoint || !hasRequiredAuth(config)) {
return {
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
}
const response = await fetch(endpoint, {
method: 'POST',
headers: buildAuthHeaders(config),
body: JSON.stringify({
model: config.model,
temperature: 0.2,
messages: [
systemPrompt
? { role: 'system', content: systemPrompt }
: null,
{ role: 'user', content: prompt }
].filter(Boolean)
}),
cache: 'no-store'
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenClaw request failed (${response.status}): ${body.slice(0, 220)}`);
}
const payload = await response.json() as ChatCompletionResponse;
const text = payload.choices?.[0]?.message?.content?.trim();
if (!text) {
throw new Error('OpenClaw returned an empty response');
}
return {
provider: 'openclaw',
model: config.model,
text
};
}