From efb161dbf4170884c6ef2daa0b5052d07901c8cc Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sun, 24 Aug 2025 15:23:57 -0700 Subject: [PATCH] implemented `ClaudeOpenRouterProvider`. --- src/agent/llm/ClaudeOpenRouterProvider.ts | 227 ++++++++++++++++++++++ src/components/ChatBox.tsx | 41 +++- src/core/config/ConfigManager.ts | 47 +++++ 3 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 src/agent/llm/ClaudeOpenRouterProvider.ts diff --git a/src/agent/llm/ClaudeOpenRouterProvider.ts b/src/agent/llm/ClaudeOpenRouterProvider.ts new file mode 100644 index 0000000..2d6c85d --- /dev/null +++ b/src/agent/llm/ClaudeOpenRouterProvider.ts @@ -0,0 +1,227 @@ +import { LLMProvider } from './LLMProvider'; +import type { StreamChunk } from './StreamingTypes'; +import type { Message } from '../core/AgentState'; +import { ConfigManager } from '../../core/config/ConfigManager'; +import { LLM_PROTOCOL } from '../../constants/llmConstants'; + +/** + * Claude (via OpenRouter) provider using the OpenAI-compatible Chat Completions API. + * Difference from the generic OpenAI provider: message content is an array of parts + * with a single text item per message (future-ready for images, tools, etc.). + */ +export class ClaudeOpenRouterProvider extends LLMProvider { + readonly name = 'Claude (OpenRouter)'; + + private isOllamaFormat: boolean | null = null; // Detected at runtime + + constructor() { + super(); + } + + /** + * Build OpenAI-compatible messages array where each message content is an array of parts. + */ + private buildRequestMessages(messages: Message[], systemPrompt?: string): Array<{ role: string; content: Array<{ type: 'text'; text: string }> }> { + const openAIMessages: Array<{ role: string; content: Array<{ type: 'text'; text: string }> }> = []; + + // Add system prompt if provided + if (systemPrompt) { + openAIMessages.push({ role: 'system', content: [{ type: 'text', text: systemPrompt }] }); + } + + // Add conversation history with preserved roles + openAIMessages.push( + ...messages.map(msg => ({ + role: msg.role, + content: [{ type: 'text' as const, text: msg.content }] + })) + ); + + return openAIMessages; + } + + /** + * Create API request with proper headers and body + */ + private async createApiRequest(messages: Array<{ role: string; content: Array<{ type: 'text'; text: string }> }>, config: ReturnType, streaming: boolean): Promise { + const headers: Record = { + 'Authorization': `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }; + + // Optional, but recommended by OpenRouter docs to set referer/title for attribution + // if (typeof window !== 'undefined') { + // headers['HTTP-Referer'] = window.location.origin; + // headers['X-Title'] = 'K.G.Studio'; + // } + + const response = await fetch(config.apiEndpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + model: config.model, + messages, + stream: streaming, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`${this.name} API request failed (${response.status}): ${response.statusText}. ${errorText}`); + } + + return response; + } + + /** + * Get current configuration values from ConfigManager + */ + private getCurrentConfig() { + const configManager = ConfigManager.instance(); + const apiKey = configManager.get('general.claude_openrouter.api_key') as string; + const model = configManager.get('general.claude_openrouter.model') as string; + const baseURL = configManager.get('general.claude_openrouter.base_url') as string; + // baseURL is the full API endpoint for OpenRouter (e.g., https://openrouter.ai/api/v1/chat/completions) + const apiEndpoint = baseURL; + return { apiKey, model, baseURL, apiEndpoint }; + } + + /** + * Detect if the response uses Ollama's raw JSON format or OpenAI's SSE format + */ + private detectStreamFormat(firstChunk: string): boolean { + // If it starts with "data: ", it's OpenAI SSE format + if (firstChunk.trim().startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) { + return false; // Not Ollama format + } + // Try to parse as JSON - if successful and has 'done' field, it's Ollama format + try { + const json = JSON.parse(firstChunk.trim()); + return typeof json.done === 'boolean'; + } catch { + return false; // Not valid JSON, assume OpenAI format + } + } + + /** + * Parse Ollama's raw JSON chunk format + */ + private parseOllamaChunk(chunk: string): { thinking?: string; content?: string; isDone?: boolean } { + try { + const json = JSON.parse(chunk.trim()); + const thinking: string | undefined = json.message?.thinking; + const content: string | undefined = json.message?.content || json.response; // Handle both chat and completion formats + return { + thinking, + content, + isDone: json.done === true + }; + } catch { + return {}; // Invalid JSON, return empty object + } + } + + /** + * Parse OpenAI's SSE format chunk + */ + private parseOpenAIChunk(line: string): { thinking?: string; content?: string; isDone?: boolean } { + if (!line.startsWith(LLM_PROTOCOL.SSE_DATA_PREFIX)) { + return {}; + } + + const data = line.slice(LLM_PROTOCOL.SSE_DATA_PREFIX.length); + if (data === LLM_PROTOCOL.SSE_DONE_MARKER) { + return { isDone: true }; + } + + try { + const json = JSON.parse(data); + const delta = json.choices?.[0]?.delta; + const thinking: string | undefined = delta?.thinking; // Some providers may stream "thinking" + const content: string | undefined = delta?.content; + return { thinking, content, isDone: false }; + } catch { + return {}; // Skip invalid JSON lines + } + } + + private async *processContentChunk( + thinking: string | undefined, + content: string | undefined, + lastSegmentType: { current: 'thinking' | 'content' | null } + ): AsyncIterableIterator { + if (typeof thinking === 'string' && thinking.length > 0) { + if (lastSegmentType.current && lastSegmentType.current !== 'thinking') { + yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR }; + } + yield { type: 'text', content: thinking }; + lastSegmentType.current = 'thinking'; + } + + if (typeof content === 'string' && content.length > 0) { + if (lastSegmentType.current && lastSegmentType.current !== 'content') { + yield { type: 'text', content: LLM_PROTOCOL.SEGMENT_SEPARATOR }; + } + yield { type: 'text', content: content }; + lastSegmentType.current = 'content'; + } + } + + async *generateStream( + messages: Message[], + systemPrompt?: string + ): AsyncIterableIterator { + const config = this.getCurrentConfig(); + const requestMessages = this.buildRequestMessages(messages, systemPrompt); + const response = await this.createApiRequest(requestMessages, config, true); + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error(`${this.name} streaming: Failed to get response reader from API response`); + } + + const decoder = new TextDecoder(); + let buffer = ''; + let firstChunkProcessed = false; + const lastSegmentType = { current: null as 'thinking' | 'content' | null }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Split by newlines for SSE (and also works for line-delimited JSON) + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Detect format on first non-empty chunk + if (!firstChunkProcessed) { + this.isOllamaFormat = this.detectStreamFormat(trimmedLine); + firstChunkProcessed = true; + } + + const { thinking, content, isDone } = this.isOllamaFormat + ? this.parseOllamaChunk(trimmedLine) + : this.parseOpenAIChunk(trimmedLine); + + if (isDone) { + yield { type: 'done', content: '' }; + return; + } + + yield* this.processContentChunk(thinking, content, lastSegmentType); + } + } + } finally { + reader.releaseLock(); + } + } +} + + diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index ef77ce4..9eea23b 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -4,6 +4,7 @@ import { UserMessage, AssistantMessage } from './chat'; import { AgentCore } from '../agent/core/AgentCore'; import { OpenAIProvider } from '../agent/llm/OpenAIProvider'; import { ClaudeProvider } from '../agent/llm/ClaudeProvider'; +import { ClaudeOpenRouterProvider } from '../agent/llm/ClaudeOpenRouterProvider'; import { GeminiProvider } from '../agent/llm/GeminiProvider'; import { LLMProvider } from '../agent/llm/LLMProvider'; import { ConfigManager } from '../core/config/ConfigManager'; @@ -36,6 +37,8 @@ const createLLMProvider = (): LLMProvider => { return new ClaudeProvider(); case 'gemini': return new GeminiProvider(); + case 'claude_openrouter': + return new ClaudeOpenRouterProvider(); case 'openai_compatible': case 'openai': default: @@ -167,17 +170,39 @@ const ChatBox: React.FC = ({ isVisible }) => { await configManager.initialize(); } - const provider = createLLMProvider(); - const agentCore = AgentCore.instance(); - agentCore.setLLMProvider(provider); + const applyProviderFromConfig = () => { + const provider = createLLMProvider(); + const agentCore = AgentCore.instance(); + agentCore.setLLMProvider(provider); + console.log(`Switched to ${provider.name} provider`); + }; - console.log(`Switched to ${provider.name} provider`); + // Initial apply + applyProviderFromConfig(); + + // Subscribe to config changes to hot-swap providers + const unsubscribe = configManager.addChangeListener((changedKeys) => { + // Hot-swap on provider change or when relevant provider config changes + if ( + changedKeys.includes('general.llm_provider') || + changedKeys.some(k => k.startsWith('general.openai.')) || + changedKeys.some(k => k.startsWith('general.openai_compatible.')) || + changedKeys.some(k => k.startsWith('general.claude_openrouter.')) || + changedKeys.some(k => k.startsWith('general.gemini.')) || + changedKeys.some(k => k.startsWith('general.claude.')) + ) { + applyProviderFromConfig(); + } + }); + + // Cleanup subscription on unmount + return unsubscribe; }; // Register the UI clear callback for external components to use registerClearChatUICallback(clearChatUI); - initializeProvider(); + const maybeUnsubscribePromise = initializeProvider(); // Auto-trigger welcome on first launch (guard against React StrictMode double-invoke only) (async () => { @@ -189,6 +214,12 @@ const ChatBox: React.FC = ({ isVisible }) => { setMessages([welcomeMessage]); } })(); + // In case initializeProvider returned a cleanup, ensure we call it + return () => { + Promise.resolve(maybeUnsubscribePromise).then((cleanup) => { + if (typeof cleanup === 'function') cleanup(); + }).catch(() => {}); + }; }, [clearChatUI]); const handleAbort = () => { diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 562211d..c94a560 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -90,6 +90,7 @@ export class ConfigManager { private storage: KGStorage; private isInitialized: boolean = false; private defaultConfig: AppConfig | null = null; + private changeListeners: Set<(changedKeys: string[]) => void> = new Set(); // Private constructor to prevent direct instantiation private constructor() { @@ -361,6 +362,8 @@ export class ConfigManager { await this.saveToStorage(); console.log(`Config updated: ${key} = ${value}`); + // Notify listeners of the specific key change + this.notifyChangeListeners([key]); } /** @@ -378,6 +381,11 @@ export class ConfigManager { await this.saveToStorage(); console.log('Config updated with multiple values:', updates); + // Notify listeners of changed keys (dot notation) + const changedKeys = this.collectDotKeys(updates as Record); + if (changedKeys.length > 0) { + this.notifyChangeListeners(changedKeys); + } } /** @@ -396,6 +404,7 @@ export class ConfigManager { await this.saveToStorage(); console.log('Config reset to defaults'); + this.notifyChangeListeners(['__all__']); } /** @@ -443,6 +452,44 @@ export class ConfigManager { current[keys[keys.length - 1]] = value; } + /** + * Subscribe to config changes. Returns an unsubscribe function. + */ + public addChangeListener(listener: (changedKeys: string[]) => void): () => void { + this.changeListeners.add(listener); + return () => this.changeListeners.delete(listener); + } + + public removeChangeListener(listener: (changedKeys: string[]) => void): void { + this.changeListeners.delete(listener); + } + + private notifyChangeListeners(changedKeys: string[]): void { + for (const listener of this.changeListeners) { + try { + listener(changedKeys); + } catch (error) { + console.error('Config change listener error:', error); + } + } + } + + /** + * Collect dot-notation keys for all leaf values in a partial config object + */ + private collectDotKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) { + keys.push(...this.collectDotKeys(v as Record, path)); + } else { + keys.push(path); + } + } + return keys; + } + /** * Check if ConfigManager is initialized */