implemented ClaudeOpenRouterProvider.

This commit is contained in:
Xiaohan-Tian
2025-08-24 15:23:57 -07:00
parent 81c6aeb966
commit efb161dbf4
3 changed files with 310 additions and 5 deletions
+227
View File
@@ -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<typeof this.getCurrentConfig>, streaming: boolean): Promise<Response> {
const headers: Record<string, string> = {
'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<StreamChunk> {
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<StreamChunk> {
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();
}
}
}
+33 -2
View File
@@ -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<ChatBoxProps> = ({ isVisible }) => {
await configManager.initialize();
}
const applyProviderFromConfig = () => {
const provider = createLLMProvider();
const agentCore = AgentCore.instance();
agentCore.setLLMProvider(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<ChatBoxProps> = ({ 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 = () => {
+47
View File
@@ -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<string, unknown>);
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<string, unknown>, 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<string, unknown>, path));
} else {
keys.push(path);
}
}
return keys;
}
/**
* Check if ConfigManager is initialized
*/