feat: add OpenRouter provider support and enhance context generation
Added support for OpenRouter as an alternative LLM provider with new settings for API key, model selection, and app metadata configuration. Enhanced context generation with improved settings management and updated worker service APIs. Includes UI updates for context settings and new observation type configurations.
This commit is contained in:
@@ -25,6 +25,7 @@ import { SessionManager } from './worker/SessionManager.js';
|
||||
import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
|
||||
import { SDKAgent } from './worker/SDKAgent.js';
|
||||
import { GeminiAgent } from './worker/GeminiAgent.js';
|
||||
import { OpenRouterAgent } from './worker/OpenRouterAgent.js';
|
||||
import { PaginationHelper } from './worker/PaginationHelper.js';
|
||||
import { SettingsManager } from './worker/SettingsManager.js';
|
||||
import { SearchManager } from './worker/SearchManager.js';
|
||||
@@ -56,6 +57,7 @@ export class WorkerService {
|
||||
private sseBroadcaster: SSEBroadcaster;
|
||||
private sdkAgent: SDKAgent;
|
||||
private geminiAgent: GeminiAgent;
|
||||
private openRouterAgent: OpenRouterAgent;
|
||||
private paginationHelper: PaginationHelper;
|
||||
private settingsManager: SettingsManager;
|
||||
private sessionEventBroadcaster: SessionEventBroadcaster;
|
||||
@@ -86,6 +88,8 @@ export class WorkerService {
|
||||
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
|
||||
this.geminiAgent = new GeminiAgent(this.dbManager, this.sessionManager);
|
||||
this.geminiAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on Gemini API failure
|
||||
this.openRouterAgent = new OpenRouterAgent(this.dbManager, this.sessionManager);
|
||||
this.openRouterAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on OpenRouter API failure
|
||||
this.paginationHelper = new PaginationHelper(this.dbManager);
|
||||
this.settingsManager = new SettingsManager(this.dbManager);
|
||||
this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this);
|
||||
@@ -103,7 +107,7 @@ export class WorkerService {
|
||||
|
||||
// Initialize route handlers (SearchRoutes will use MCP client initially, then switch to SearchManager after DB init)
|
||||
this.viewerRoutes = new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager);
|
||||
this.sessionRoutes = new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.sessionEventBroadcaster, this);
|
||||
this.sessionRoutes = new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this);
|
||||
this.dataRoutes = new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime);
|
||||
// SearchRoutes needs SearchManager which requires initialized DB - will be created in initializeBackground()
|
||||
this.searchRoutes = null;
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ActiveSession {
|
||||
pendingProcessingIds: Set<number>; // Track ALL message IDs yielded but not yet processed
|
||||
earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps)
|
||||
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
|
||||
currentProvider: 'claude' | 'gemini' | null; // Track which provider is currently running
|
||||
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
|
||||
}
|
||||
|
||||
export interface PendingMessage {
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* OpenRouterAgent: OpenRouter-based observation extraction
|
||||
*
|
||||
* Alternative to SDKAgent that uses OpenRouter's unified API
|
||||
* for accessing 100+ models from different providers.
|
||||
*
|
||||
* Responsibility:
|
||||
* - Call OpenRouter REST API for observation extraction
|
||||
* - Parse XML responses (same format as Claude/Gemini)
|
||||
* - Sync to database and Chroma
|
||||
* - Support dynamic model selection across providers
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { SessionManager } from './SessionManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { parseObservations, parseSummary } from '../../sdk/parser.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import type { ActiveSession, ConversationMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
|
||||
// OpenRouter API endpoint
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
// OpenAI-compatible message format
|
||||
interface OpenAIMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface OpenRouterResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: string;
|
||||
};
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: {
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Forward declaration for fallback agent type
|
||||
type FallbackAgent = {
|
||||
startSession(session: ActiveSession, worker?: any): Promise<void>;
|
||||
};
|
||||
|
||||
export class OpenRouterAgent {
|
||||
private dbManager: DatabaseManager;
|
||||
private sessionManager: SessionManager;
|
||||
private fallbackAgent: FallbackAgent | null = null;
|
||||
|
||||
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
|
||||
this.dbManager = dbManager;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the fallback agent (Claude SDK) for when OpenRouter API fails
|
||||
* Must be set after construction to avoid circular dependency
|
||||
*/
|
||||
setFallbackAgent(agent: FallbackAgent): void {
|
||||
this.fallbackAgent = agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should trigger fallback to Claude
|
||||
*/
|
||||
private shouldFallbackToClaude(error: any): boolean {
|
||||
const message = error?.message || '';
|
||||
// Fall back on rate limit (429), server errors (5xx), or network issues
|
||||
return (
|
||||
message.includes('429') ||
|
||||
message.includes('500') ||
|
||||
message.includes('502') ||
|
||||
message.includes('503') ||
|
||||
message.includes('ECONNREFUSED') ||
|
||||
message.includes('ETIMEDOUT') ||
|
||||
message.includes('fetch failed')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OpenRouter agent for a session
|
||||
* Uses multi-turn conversation to maintain context across messages
|
||||
*/
|
||||
async startSession(session: ActiveSession, worker?: any): Promise<void> {
|
||||
try {
|
||||
// Get OpenRouter configuration
|
||||
const { apiKey, model, siteUrl, appName } = this.getOpenRouterConfig();
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenRouter API key not configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
// Load active mode
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
|
||||
// Build initial prompt
|
||||
const initPrompt = session.lastPromptNumber === 1
|
||||
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
|
||||
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode);
|
||||
|
||||
// Add to conversation history and query OpenRouter with full context
|
||||
session.conversationHistory.push({ role: 'user', content: initPrompt });
|
||||
const initResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
|
||||
|
||||
if (initResponse.content) {
|
||||
// Add response to conversation history
|
||||
session.conversationHistory.push({ role: 'assistant', content: initResponse.content });
|
||||
|
||||
// Track token usage
|
||||
const tokensUsed = initResponse.tokensUsed || 0;
|
||||
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate
|
||||
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
|
||||
|
||||
// Process response (no original timestamp for init - not from queue)
|
||||
await this.processOpenRouterResponse(session, initResponse.content, worker, tokensUsed, null);
|
||||
}
|
||||
|
||||
// Process pending messages
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// Capture earliest timestamp BEFORE processing (will be cleared after)
|
||||
const originalTimestamp = session.earliestPendingTimestamp;
|
||||
|
||||
if (message.type === 'observation') {
|
||||
// Update last prompt number
|
||||
if (message.prompt_number !== undefined) {
|
||||
session.lastPromptNumber = message.prompt_number;
|
||||
}
|
||||
|
||||
// Build observation prompt
|
||||
const obsPrompt = buildObservationPrompt({
|
||||
id: 0,
|
||||
tool_name: message.tool_name!,
|
||||
tool_input: JSON.stringify(message.tool_input),
|
||||
tool_output: JSON.stringify(message.tool_response),
|
||||
created_at_epoch: originalTimestamp ?? Date.now(),
|
||||
cwd: message.cwd
|
||||
});
|
||||
|
||||
// Add to conversation history and query OpenRouter with full context
|
||||
session.conversationHistory.push({ role: 'user', content: obsPrompt });
|
||||
const obsResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
|
||||
|
||||
if (obsResponse.content) {
|
||||
// Add response to conversation history
|
||||
session.conversationHistory.push({ role: 'assistant', content: obsResponse.content });
|
||||
|
||||
const tokensUsed = obsResponse.tokensUsed || 0;
|
||||
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7);
|
||||
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
|
||||
await this.processOpenRouterResponse(session, obsResponse.content, worker, tokensUsed, originalTimestamp);
|
||||
} else {
|
||||
// Empty response - still mark messages as processed to avoid stuck state
|
||||
logger.warn('SDK', 'Empty OpenRouter response for observation, marking as processed', {
|
||||
sessionId: session.sessionDbId,
|
||||
toolName: message.tool_name
|
||||
});
|
||||
await this.markMessagesProcessed(session, worker);
|
||||
}
|
||||
|
||||
} else if (message.type === 'summarize') {
|
||||
// Build summary prompt
|
||||
const summaryPrompt = buildSummaryPrompt({
|
||||
id: session.sessionDbId,
|
||||
sdk_session_id: session.sdkSessionId,
|
||||
project: session.project,
|
||||
user_prompt: session.userPrompt,
|
||||
last_user_message: message.last_user_message || '',
|
||||
last_assistant_message: message.last_assistant_message || ''
|
||||
}, mode);
|
||||
|
||||
// Add to conversation history and query OpenRouter with full context
|
||||
session.conversationHistory.push({ role: 'user', content: summaryPrompt });
|
||||
const summaryResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
|
||||
|
||||
if (summaryResponse.content) {
|
||||
// Add response to conversation history
|
||||
session.conversationHistory.push({ role: 'assistant', content: summaryResponse.content });
|
||||
|
||||
const tokensUsed = summaryResponse.tokensUsed || 0;
|
||||
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7);
|
||||
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
|
||||
await this.processOpenRouterResponse(session, summaryResponse.content, worker, tokensUsed, originalTimestamp);
|
||||
} else {
|
||||
// Empty response - still mark messages as processed to avoid stuck state
|
||||
logger.warn('SDK', 'Empty OpenRouter response for summary, marking as processed', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
await this.markMessagesProcessed(session, worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session complete
|
||||
const sessionDuration = Date.now() - session.startTime;
|
||||
logger.success('SDK', 'OpenRouter agent completed', {
|
||||
sessionId: session.sessionDbId,
|
||||
duration: `${(sessionDuration / 1000).toFixed(1)}s`,
|
||||
historyLength: session.conversationHistory.length,
|
||||
model
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
logger.warn('SDK', 'OpenRouter agent aborted', { sessionId: session.sessionDbId });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if we should fall back to Claude
|
||||
if (this.shouldFallbackToClaude(error) && this.fallbackAgent) {
|
||||
logger.warn('SDK', 'OpenRouter API failed, falling back to Claude SDK', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
error: error.message,
|
||||
historyLength: session.conversationHistory.length
|
||||
});
|
||||
|
||||
// Reset any 'processing' messages back to 'pending' so Claude can retry them
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const resetCount = pendingStore.resetStuckMessages(0); // 0 = reset ALL processing messages
|
||||
if (resetCount > 0) {
|
||||
logger.info('SDK', 'Reset processing messages for fallback', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
resetCount
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to Claude - it will use the same session with shared conversationHistory
|
||||
return this.fallbackAgent.startSession(session, worker);
|
||||
}
|
||||
|
||||
logger.failure('SDK', 'OpenRouter agent error', { sessionDbId: session.sessionDbId }, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert shared ConversationMessage array to OpenAI-compatible message format
|
||||
*/
|
||||
private conversationToOpenAIMessages(history: ConversationMessage[]): OpenAIMessage[] {
|
||||
return history.map(msg => ({
|
||||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: msg.content
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query OpenRouter via REST API with full conversation history (multi-turn)
|
||||
* Sends the entire conversation context for coherent responses
|
||||
*/
|
||||
private async queryOpenRouterMultiTurn(
|
||||
history: ConversationMessage[],
|
||||
apiKey: string,
|
||||
model: string,
|
||||
siteUrl?: string,
|
||||
appName?: string
|
||||
): Promise<{ content: string; tokensUsed?: number }> {
|
||||
const messages = this.conversationToOpenAIMessages(history);
|
||||
const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
|
||||
|
||||
logger.debug('SDK', `Querying OpenRouter multi-turn (${model})`, {
|
||||
turns: history.length,
|
||||
totalChars
|
||||
});
|
||||
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': siteUrl || 'https://github.com/thedotmack/claude-mem',
|
||||
'X-Title': appName || 'claude-mem',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3, // Lower temperature for structured extraction
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenRouterResponse;
|
||||
|
||||
// Check for API error in response body
|
||||
if (data.error) {
|
||||
throw new Error(`OpenRouter API error: ${data.error.code} - ${data.error.message}`);
|
||||
}
|
||||
|
||||
if (!data.choices?.[0]?.message?.content) {
|
||||
logger.warn('SDK', 'Empty response from OpenRouter');
|
||||
return { content: '' };
|
||||
}
|
||||
|
||||
const content = data.choices[0].message.content;
|
||||
const tokensUsed = data.usage?.total_tokens;
|
||||
|
||||
return { content, tokensUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OpenRouter response (same format as Claude/Gemini)
|
||||
* @param originalTimestamp - Original epoch when message was queued (for backlog processing accuracy)
|
||||
*/
|
||||
private async processOpenRouterResponse(
|
||||
session: ActiveSession,
|
||||
text: string,
|
||||
worker: any | undefined,
|
||||
discoveryTokens: number,
|
||||
originalTimestamp: number | null
|
||||
): Promise<void> {
|
||||
// Parse observations (same XML format)
|
||||
const observations = parseObservations(text, session.claudeSessionId);
|
||||
|
||||
// Store observations with original timestamp (if processing backlog) or current time
|
||||
for (const obs of observations) {
|
||||
const { id: obsId, createdAtEpoch } = this.dbManager.getSessionStore().storeObservation(
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
obs,
|
||||
session.lastPromptNumber,
|
||||
discoveryTokens,
|
||||
originalTimestamp ?? undefined
|
||||
);
|
||||
|
||||
logger.info('SDK', 'OpenRouter observation saved', {
|
||||
sessionId: session.sessionDbId,
|
||||
obsId,
|
||||
type: obs.type,
|
||||
title: obs.title || '(untitled)'
|
||||
});
|
||||
|
||||
// Sync to Chroma
|
||||
this.dbManager.getChromaSync().syncObservation(
|
||||
obsId,
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
obs,
|
||||
session.lastPromptNumber,
|
||||
createdAtEpoch,
|
||||
discoveryTokens
|
||||
).catch(err => {
|
||||
logger.warn('SDK', 'OpenRouter chroma sync failed', { obsId }, err);
|
||||
});
|
||||
|
||||
// Broadcast to SSE clients
|
||||
if (worker && worker.sseBroadcaster) {
|
||||
worker.sseBroadcaster.broadcast({
|
||||
type: 'new_observation',
|
||||
observation: {
|
||||
id: obsId,
|
||||
sdk_session_id: session.sdkSessionId,
|
||||
session_id: session.claudeSessionId,
|
||||
type: obs.type,
|
||||
title: obs.title,
|
||||
subtitle: obs.subtitle,
|
||||
text: null,
|
||||
narrative: obs.narrative || null,
|
||||
facts: JSON.stringify(obs.facts || []),
|
||||
concepts: JSON.stringify(obs.concepts || []),
|
||||
files_read: JSON.stringify(obs.files_read || []),
|
||||
files_modified: JSON.stringify(obs.files_modified || []),
|
||||
project: session.project,
|
||||
prompt_number: session.lastPromptNumber,
|
||||
created_at_epoch: createdAtEpoch
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse summary
|
||||
const summary = parseSummary(text, session.sessionDbId);
|
||||
|
||||
if (summary) {
|
||||
// Convert nullable fields to empty strings for storeSummary
|
||||
const summaryForStore = {
|
||||
request: summary.request || '',
|
||||
investigated: summary.investigated || '',
|
||||
learned: summary.learned || '',
|
||||
completed: summary.completed || '',
|
||||
next_steps: summary.next_steps || '',
|
||||
notes: summary.notes
|
||||
};
|
||||
|
||||
const { id: summaryId, createdAtEpoch } = this.dbManager.getSessionStore().storeSummary(
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
summaryForStore,
|
||||
session.lastPromptNumber,
|
||||
discoveryTokens,
|
||||
originalTimestamp ?? undefined
|
||||
);
|
||||
|
||||
logger.info('SDK', 'OpenRouter summary saved', {
|
||||
sessionId: session.sessionDbId,
|
||||
summaryId,
|
||||
request: summary.request || '(no request)'
|
||||
});
|
||||
|
||||
// Sync to Chroma
|
||||
this.dbManager.getChromaSync().syncSummary(
|
||||
summaryId,
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
summaryForStore,
|
||||
session.lastPromptNumber,
|
||||
createdAtEpoch,
|
||||
discoveryTokens
|
||||
).catch(err => {
|
||||
logger.warn('SDK', 'OpenRouter chroma sync failed', { summaryId }, err);
|
||||
});
|
||||
|
||||
// Broadcast to SSE clients
|
||||
if (worker && worker.sseBroadcaster) {
|
||||
worker.sseBroadcaster.broadcast({
|
||||
type: 'new_summary',
|
||||
summary: {
|
||||
id: summaryId,
|
||||
session_id: session.claudeSessionId,
|
||||
request: summary.request,
|
||||
investigated: summary.investigated,
|
||||
learned: summary.learned,
|
||||
completed: summary.completed,
|
||||
next_steps: summary.next_steps,
|
||||
notes: summary.notes,
|
||||
project: session.project,
|
||||
prompt_number: session.lastPromptNumber,
|
||||
created_at_epoch: createdAtEpoch
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark messages as processed
|
||||
await this.markMessagesProcessed(session, worker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark pending messages as processed
|
||||
*/
|
||||
private async markMessagesProcessed(session: ActiveSession, worker: any | undefined): Promise<void> {
|
||||
const pendingMessageStore = this.sessionManager.getPendingMessageStore();
|
||||
if (session.pendingProcessingIds.size > 0) {
|
||||
for (const messageId of session.pendingProcessingIds) {
|
||||
pendingMessageStore.markProcessed(messageId);
|
||||
}
|
||||
logger.debug('SDK', 'OpenRouter messages marked as processed', {
|
||||
sessionId: session.sessionDbId,
|
||||
count: session.pendingProcessingIds.size
|
||||
});
|
||||
session.pendingProcessingIds.clear();
|
||||
|
||||
const deletedCount = pendingMessageStore.cleanupProcessed(100);
|
||||
if (deletedCount > 0) {
|
||||
logger.debug('SDK', 'OpenRouter cleaned up old processed messages', { deletedCount });
|
||||
}
|
||||
}
|
||||
|
||||
if (worker && typeof worker.broadcastProcessingStatus === 'function') {
|
||||
worker.broadcastProcessingStatus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenRouter configuration from settings or environment
|
||||
*/
|
||||
private getOpenRouterConfig(): { apiKey: string; model: string; siteUrl?: string; appName?: string } {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// API key: check settings first, then environment variable
|
||||
const apiKey = settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || '';
|
||||
|
||||
// Model: from settings or default
|
||||
const model = settings.CLAUDE_MEM_OPENROUTER_MODEL || 'anthropic/claude-3.5-sonnet';
|
||||
|
||||
// Optional analytics headers
|
||||
const siteUrl = settings.CLAUDE_MEM_OPENROUTER_SITE_URL || '';
|
||||
const appName = settings.CLAUDE_MEM_OPENROUTER_APP_NAME || 'claude-mem';
|
||||
|
||||
return { apiKey, model, siteUrl, appName };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenRouter is available (has API key configured)
|
||||
*/
|
||||
export function isOpenRouterAvailable(): boolean {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return !!(settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenRouter is the selected provider
|
||||
*/
|
||||
export function isOpenRouterSelected(): boolean {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return settings.CLAUDE_MEM_PROVIDER === 'openrouter';
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { SessionManager } from '../../SessionManager.js';
|
||||
import { DatabaseManager } from '../../DatabaseManager.js';
|
||||
import { SDKAgent } from '../../SDKAgent.js';
|
||||
import { GeminiAgent, isGeminiSelected, isGeminiAvailable } from '../../GeminiAgent.js';
|
||||
import { OpenRouterAgent, isOpenRouterSelected, isOpenRouterAvailable } from '../../OpenRouterAgent.js';
|
||||
import type { WorkerService } from '../../../worker-service.js';
|
||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||
import { SessionEventBroadcaster } from '../../events/SessionEventBroadcaster.js';
|
||||
@@ -29,6 +30,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
private dbManager: DatabaseManager,
|
||||
private sdkAgent: SDKAgent,
|
||||
private geminiAgent: GeminiAgent,
|
||||
private openRouterAgent: OpenRouterAgent,
|
||||
private eventBroadcaster: SessionEventBroadcaster,
|
||||
private workerService: WorkerService
|
||||
) {
|
||||
@@ -41,12 +43,20 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Get the appropriate agent based on settings
|
||||
* Throws error if Gemini is selected but not configured (no silent fallback)
|
||||
* Throws error if provider is selected but not configured (no silent fallback)
|
||||
*
|
||||
* Note: Session linking via claudeSessionId allows provider switching mid-session.
|
||||
* The conversationHistory on ActiveSession maintains context across providers.
|
||||
*/
|
||||
private getActiveAgent(): SDKAgent | GeminiAgent {
|
||||
private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent {
|
||||
if (isOpenRouterSelected()) {
|
||||
if (isOpenRouterAvailable()) {
|
||||
logger.debug('SESSION', 'Using OpenRouter agent');
|
||||
return this.openRouterAgent;
|
||||
} else {
|
||||
throw new Error('OpenRouter provider selected but no API key configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
}
|
||||
if (isGeminiSelected()) {
|
||||
if (isGeminiAvailable()) {
|
||||
logger.debug('SESSION', 'Using Gemini agent');
|
||||
@@ -61,7 +71,10 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
/**
|
||||
* Get the currently selected provider name
|
||||
*/
|
||||
private getSelectedProvider(): 'claude' | 'gemini' {
|
||||
private getSelectedProvider(): 'claude' | 'gemini' | 'openrouter' {
|
||||
if (isOpenRouterSelected() && isOpenRouterAvailable()) {
|
||||
return 'openrouter';
|
||||
}
|
||||
return (isGeminiSelected() && isGeminiAvailable()) ? 'gemini' : 'claude';
|
||||
}
|
||||
|
||||
@@ -104,13 +117,13 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private startGeneratorWithProvider(
|
||||
session: ReturnType<typeof this.sessionManager.getSession>,
|
||||
provider: 'claude' | 'gemini',
|
||||
provider: 'claude' | 'gemini' | 'openrouter',
|
||||
source: string
|
||||
): void {
|
||||
if (!session) return;
|
||||
|
||||
const agent = provider === 'gemini' ? this.geminiAgent : this.sdkAgent;
|
||||
const agentName = provider === 'gemini' ? 'Gemini' : 'Claude SDK';
|
||||
const agent = provider === 'openrouter' ? this.openRouterAgent : (provider === 'gemini' ? this.geminiAgent : this.sdkAgent);
|
||||
const agentName = provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK');
|
||||
|
||||
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
|
||||
@@ -216,9 +216,9 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
private validateSettings(settings: any): { valid: boolean; error?: string } {
|
||||
// Validate CLAUDE_MEM_PROVIDER
|
||||
if (settings.CLAUDE_MEM_PROVIDER) {
|
||||
const validProviders = ['claude', 'gemini'];
|
||||
if (!validProviders.includes(settings.CLAUDE_MEM_PROVIDER)) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_PROVIDER must be "claude" or "gemini"' };
|
||||
const validProviders = ['claude', 'gemini', 'openrouter'];
|
||||
if (!validProviders.includes(settings.CLAUDE_MEM_PROVIDER)) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_PROVIDER must be "claude", "gemini", or "openrouter"' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user