diff --git a/.env.example b/.env.example index 863f578..15ce2c0 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,18 @@ GOOGLE_API_KEY=your_key_here # DATABASE_PATH=data/proxy.db # SQLite file path (default: data/proxy.db relative to gateway cwd) # Service ports +PORT=3001 # OPENROUTER_PORT=4010 # OpenRouter integration (memory API served here) # DASHBOARD_PORT=3000 # Dashboard UI # Memory is embedded in the OpenRouter process (no separate service). # MEMORY_DB_PATH=./memory.db # SQLite path for memory store (used by OpenRouter) +# Scheduled ingest (deduplicated memory) +# CONVERSATION_LOG_PATH=./data/conversation-log.jsonl +# INGEST_CHECKPOINT_PATH=./data/ingest-checkpoint.json +# MEMORY_INGEST_URL=http://localhost:4010 +# INGEST_RATE_LIMIT_MS=1000 # Optional x402 passthrough for OpenRouter access X402_BASE_URL=x402_supported_provider_url PRIVATE_KEY= diff --git a/gateway/src/costs/ollama.yaml b/gateway/src/costs/ollama.yaml new file mode 100644 index 0000000..2d7bb29 --- /dev/null +++ b/gateway/src/costs/ollama.yaml @@ -0,0 +1,59 @@ +provider: "ollama" +currency: "USD" +unit: "MTok" +models: + # Ollama runs models locally — all costs are zero. + # Users may add custom model entries here if needed. + llama3.3: + input: 0.00 + output: 0.00 + llama3.2: + input: 0.00 + output: 0.00 + llama3.1: + input: 0.00 + output: 0.00 + llama3: + input: 0.00 + output: 0.00 + gemma3: + input: 0.00 + output: 0.00 + gemma2: + input: 0.00 + output: 0.00 + qwen3: + input: 0.00 + output: 0.00 + qwen2.5-coder: + input: 0.00 + output: 0.00 + deepseek-r1: + input: 0.00 + output: 0.00 + deepseek-coder-v2: + input: 0.00 + output: 0.00 + phi4: + input: 0.00 + output: 0.00 + phi3: + input: 0.00 + output: 0.00 + mistral: + input: 0.00 + output: 0.00 + mixtral: + input: 0.00 + output: 0.00 + codellama: + input: 0.00 + output: 0.00 + starcoder2: + input: 0.00 + output: 0.00 +metadata: + last_updated: "2026-02-03" + source: "https://ollama.com" + notes: "Ollama runs models locally. All API costs are zero — hardware costs are borne by the user." + version: "1.0" diff --git a/gateway/src/domain/services/provider-registry.ts b/gateway/src/domain/services/provider-registry.ts new file mode 100644 index 0000000..e2a227e --- /dev/null +++ b/gateway/src/domain/services/provider-registry.ts @@ -0,0 +1,92 @@ +import { AIProvider } from '../types/provider.js'; +import { AnthropicProvider } from '../providers/anthropic-provider.js'; +import { OpenAIProvider } from '../providers/openai-provider.js'; +import { OpenRouterProvider } from '../providers/openrouter-provider.js'; +import { XAIProvider } from '../providers/xai-provider.js'; +import { ZAIProvider } from '../providers/zai-provider.js'; +import { GoogleProvider } from '../providers/google-provider.js'; +import { OllamaProvider } from '../providers/ollama-provider.js'; + +export enum Provider { + ANTHROPIC = 'anthropic', + OPENAI = 'openai', + OPENROUTER = 'openrouter', + XAI = 'xAI', + ZAI = 'zai', + GOOGLE = 'google', + OLLAMA = 'ollama' +} + +export interface ProviderSelectionRule { + match: (modelName: string) => boolean; +} + +export interface ProviderPlugin { + id: Provider; + create: () => AIProvider; + selectionRules?: ProviderSelectionRule[]; +} + +/** + * Central registry for provider creation and selection hints. + * Keeps wiring in one place and reduces per-provider boilerplate. + */ +export class ProviderRegistry { + private readonly instances = new Map(); + + constructor(private readonly plugins: ProviderPlugin[]) {} + + listProviders(): Provider[] { + return this.plugins.map(p => p.id); + } + + getOrCreateProvider(id: Provider): AIProvider { + if (!this.instances.has(id)) { + const plugin = this.plugins.find(p => p.id === id); + if (!plugin) { + throw new Error(`Unknown provider: ${id}`); + } + this.instances.set(id, plugin.create()); + } + + const provider = this.instances.get(id); + if (!provider) { + throw new Error(`Failed to create provider: ${id}`); + } + return provider; + } + + getAvailableProviders(): Provider[] { + return this.listProviders().filter(id => { + const provider = this.getOrCreateProvider(id); + return provider.isConfigured(); + }); + } + + /** + * Return the first preferred provider whose rule matches the model name and is available. + */ + findPreferredProvider(modelName: string, available: Provider[]): Provider | undefined { + for (const plugin of this.plugins) { + if (!plugin.selectionRules || !available.includes(plugin.id)) continue; + if (plugin.selectionRules.some(rule => rule.match(modelName))) { + return plugin.id; + } + } + return undefined; + } +} + +export function createDefaultProviderRegistry(): ProviderRegistry { + const plugins: ProviderPlugin[] = [ + { id: Provider.ANTHROPIC, create: () => new AnthropicProvider() }, + { id: Provider.OPENAI, create: () => new OpenAIProvider() }, + { id: Provider.OPENROUTER, create: () => new OpenRouterProvider() }, + { id: Provider.XAI, create: () => new XAIProvider(), selectionRules: [{ match: model => model.includes('grok-') || model.includes('grok_beta') }] }, + { id: Provider.ZAI, create: () => new ZAIProvider() }, + { id: Provider.GOOGLE, create: () => new GoogleProvider(), selectionRules: [{ match: model => model.toLowerCase().includes('gemini') }] }, + { id: Provider.OLLAMA, create: () => new OllamaProvider(), selectionRules: [{ match: model => model.startsWith('ollama/') }] }, + ]; + + return new ProviderRegistry(plugins); +} diff --git a/gateway/src/infrastructure/config/app-config.ts b/gateway/src/infrastructure/config/app-config.ts new file mode 100644 index 0000000..c256a1c --- /dev/null +++ b/gateway/src/infrastructure/config/app-config.ts @@ -0,0 +1,167 @@ +/** + * Centralized application configuration + * All environment variables are validated and accessed through this class + */ + +export class AppConfig { + // Server configuration + readonly server = { + port: this.getNumber('PORT', 3001), + environment: this.getString('NODE_ENV', 'development'), + isDevelopment: this.getString('NODE_ENV', 'development') === 'development', + isProduction: this.getString('NODE_ENV', 'development') === 'production', + version: this.getOptionalString('npm_package_version') || 'dev', + }; + + // x402 Payment configuration + readonly x402 = { + enabled: this.has('PRIVATE_KEY'), + privateKey: this.getOptionalString('PRIVATE_KEY'), + baseUrl: this.getString('X402_BASE_URL', 'https://x402.ekailabs.xyz'), + + // Helper methods + get chatCompletionsUrl() { + return `${this.baseUrl}/v1/chat/completions`; + }, + get messagesUrl() { + return `${this.baseUrl}/v1/messages`; + }, + }; + + // Provider API Keys + readonly providers = { + anthropic: { + apiKey: this.getOptionalString('ANTHROPIC_API_KEY'), + enabled: this.has('ANTHROPIC_API_KEY'), + }, + openai: { + apiKey: this.getOptionalString('OPENAI_API_KEY'), + enabled: this.has('OPENAI_API_KEY'), + }, + openrouter: { + apiKey: this.getOptionalString('OPENROUTER_API_KEY'), + enabled: this.has('OPENROUTER_API_KEY'), + }, + xai: { + apiKey: this.getOptionalString('XAI_API_KEY'), + enabled: this.has('XAI_API_KEY'), + }, + zai: { + apiKey: this.getOptionalString('ZAI_API_KEY'), + enabled: this.has('ZAI_API_KEY'), + }, + google: { + apiKey: this.getOptionalString('GOOGLE_API_KEY'), + enabled: this.has('GOOGLE_API_KEY'), + }, + ollama: { + baseUrl: this.getString('OLLAMA_BASE_URL', 'http://localhost:11434/v1'), + apiKey: this.getOptionalString('OLLAMA_API_KEY'), + enabled: this.has('OLLAMA_BASE_URL'), + }, + }; + + // Telemetry configuration + readonly telemetry = { + enabled: this.getBoolean('ENABLE_TELEMETRY', true), + endpoint: this.getOptionalString('TELEMETRY_ENDPOINT'), + }; + + // OpenRouter-specific configuration + readonly openrouter = { + skipPricingRefresh: this.getBoolean('SKIP_OPENROUTER_PRICING_REFRESH', false), + pricingTimeoutMs: this.getNumber('OPENROUTER_PRICING_TIMEOUT_MS', 4000), + pricingRetries: this.getNumber('OPENROUTER_PRICING_RETRIES', 2), + }; + + // Feature flags + readonly features = { + usageTracking: this.getBoolean('ENABLE_USAGE_TRACKING', true), + }; + + // Memory service configuration (FIFO file backend by default) + readonly memory = { + backend: this.getString('MEMORY_BACKEND', 'file'), + maxItems: this.getNumber('MEMORY_MAX_ITEMS', 20), + } as const; + + // Helper methods + private has(key: string): boolean { + return !!process.env[key]; + } + + private getString(key: string, defaultValue: string): string; + private getString(key: string): string; + private getString(key: string, defaultValue?: string): string { + const value = process.env[key] || defaultValue; + if (value === undefined) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; + } + + private getOptionalString(key: string): string | undefined { + return process.env[key]; + } + + private getNumber(key: string, defaultValue: number): number { + const value = process.env[key]; + if (!value) return defaultValue; + const num = parseInt(value, 10); + if (isNaN(num)) { + throw new Error(`Invalid number for environment variable ${key}: ${value}`); + } + return num; + } + + private getBoolean(key: string, defaultValue: boolean): boolean { + const value = process.env[key]; + if (!value) return defaultValue; + return value.toLowerCase() === 'true' || value === '1'; + } + + /** + * Validate that at least one authentication method is configured + */ + validate(): void { + const hasApiKeys = Object.values(this.providers).some(p => p.enabled); + const hasX402 = this.x402.enabled; + + if (!hasApiKeys && !hasX402) { + throw new Error( + 'No authentication configured. Set either:\n' + + ' 1. At least one provider API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\n' + + ' 2. PRIVATE_KEY for x402 payment mode' + ); + } + } + + /** + * Get human-readable mode description + */ + getMode(): 'x402-only' | 'hybrid' | 'byok' { + const hasApiKeys = Object.values(this.providers).some(p => p.enabled); + const hasX402 = this.x402.enabled; + + if (!hasApiKeys && hasX402) return 'x402-only'; + if (hasApiKeys && hasX402) return 'hybrid'; + return 'byok'; + } +} + +// Singleton instance +let configInstance: AppConfig | null = null; + +export function getConfig(): AppConfig { + if (!configInstance) { + configInstance = new AppConfig(); + configInstance.validate(); + } + return configInstance; +} + +// For testing - reset config +export function resetConfig(): void { + configInstance = null; +} + diff --git a/gateway/src/infrastructure/passthrough/responses-passthrough-registry.ts b/gateway/src/infrastructure/passthrough/responses-passthrough-registry.ts new file mode 100644 index 0000000..d67bc09 --- /dev/null +++ b/gateway/src/infrastructure/passthrough/responses-passthrough-registry.ts @@ -0,0 +1,65 @@ +import { ResponsesPassthrough, ResponsesPassthroughConfig } from './responses-passthrough.js'; +import { OpenAIResponsesPassthrough } from './openai-responses-passthrough.js'; +import { OllamaResponsesPassthrough } from './ollama-responses-passthrough.js'; +import { loadResponsesProviderDefinitions, ResponsesProviderDefinition } from './responses-provider-config.js'; +import { logger } from '../utils/logger.js'; + +interface ProviderEntry { + definition: ResponsesProviderDefinition; + passthrough?: ResponsesPassthrough; +} + +const passthroughFactories: Record ResponsesPassthrough> = { + openai: (config) => new OpenAIResponsesPassthrough(config), + ollama: (config) => new OllamaResponsesPassthrough(config), +}; + +export class ResponsesPassthroughRegistry { + private readonly providers = new Map(); + + constructor(definitions: ResponsesProviderDefinition[]) { + definitions.forEach(definition => { + if (!passthroughFactories[definition.provider]) { + logger.warn('No responses passthrough factory registered for provider', { + provider: definition.provider, + module: 'responses-passthrough-registry', + }); + return; + } + this.providers.set(definition.provider, { definition }); + }); + } + + listProviders(): string[] { + return Array.from(this.providers.keys()); + } + + getSupportedClientFormats(provider: string): string[] { + const entry = this.providers.get(provider); + return entry?.definition.config.supportedClientFormats ?? []; + } + + getConfig(provider: string): ResponsesPassthroughConfig | undefined { + const entry = this.providers.get(provider); + return entry?.definition.config; + } + + getPassthrough(provider: string): ResponsesPassthrough | undefined { + const entry = this.providers.get(provider); + if (!entry) return undefined; + + if (!entry.passthrough) { + const factory = passthroughFactories[entry.definition.provider]; + if (!factory) return undefined; + entry.passthrough = factory(entry.definition.config); + this.providers.set(provider, entry); + } + + return entry.passthrough; + } +} + +export function createResponsesPassthroughRegistry(): ResponsesPassthroughRegistry { + const definitions = loadResponsesProviderDefinitions(); + return new ResponsesPassthroughRegistry(definitions); +} diff --git a/integrations/openrouter/.env.example b/integrations/openrouter/.env.example index cc18dd5..05c2cc8 100644 --- a/integrations/openrouter/.env.example +++ b/integrations/openrouter/.env.example @@ -6,3 +6,9 @@ OPENROUTER_API_KEY=your_key_here # Server port (optional, defaults to 4010) # PORT=4010 + +# Scheduled deduplicated ingest +# CONVERSATION_LOG_PATH=./data/conversation-log.jsonl +# INGEST_CHECKPOINT_PATH=./data/ingest-checkpoint.json +# MEMORY_INGEST_URL=http://localhost:4010 +# INGEST_RATE_LIMIT_MS=1000 diff --git a/integrations/openrouter/src/config.ts b/integrations/openrouter/src/config.ts index bab9dc4..4282591 100644 --- a/integrations/openrouter/src/config.ts +++ b/integrations/openrouter/src/config.ts @@ -24,3 +24,4 @@ if (!OPENROUTER_API_KEY) { export const MEMORY_DB_PATH = process.env.MEMORY_DB_PATH ?? './memory.db'; export const PORT = parseInt(process.env.OPENROUTER_PORT || process.env.PORT || '4010', 10); +export const CONVERSATION_LOG_PATH = process.env.CONVERSATION_LOG_PATH ?? './data/conversation-log.jsonl'; diff --git a/integrations/openrouter/src/conversation-log.ts b/integrations/openrouter/src/conversation-log.ts new file mode 100644 index 0000000..3eeee8a --- /dev/null +++ b/integrations/openrouter/src/conversation-log.ts @@ -0,0 +1,64 @@ +import { mkdir, appendFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +export interface ConversationMessage { + role: string; + content: string; +} + +interface ConversationLogEntry { + id: string; + ts: number; + agentId: string; + userId?: string; + messages: ConversationMessage[]; +} + +function normalizeMessageContent(content: unknown): string { + if (typeof content === 'string') return content.trim(); + if (Array.isArray(content)) { + return content + .filter((part: any) => part?.type === 'text' && typeof part?.text === 'string') + .map((part: any) => part.text.trim()) + .filter(Boolean) + .join(' '); + } + return ''; +} + +export function normalizeMessages(messages: unknown): ConversationMessage[] { + if (!Array.isArray(messages)) return []; + + return messages + .map((msg: any) => ({ + role: typeof msg?.role === 'string' ? msg.role : 'user', + content: normalizeMessageContent(msg?.content), + })) + .filter((msg) => msg.content.length > 0); +} + +export function appendConversationLog( + logPath: string, + input: { agentId: string; userId?: string; messages: ConversationMessage[] }, +): void { + if (!input.messages.length) return; + + const entry: ConversationLogEntry = { + id: randomUUID(), + ts: Date.now(), + agentId: input.agentId, + userId: input.userId, + messages: input.messages, + }; + + const line = `${JSON.stringify(entry)}\n`; + setImmediate(async () => { + try { + await mkdir(dirname(logPath), { recursive: true }); + await appendFile(logPath, line, 'utf8'); + } catch (err: any) { + console.warn(`[conversation-log] failed to append: ${err.message}`); + } + }); +} diff --git a/integrations/openrouter/src/server.ts b/integrations/openrouter/src/server.ts index 1b9e040..780f10c 100644 --- a/integrations/openrouter/src/server.ts +++ b/integrations/openrouter/src/server.ts @@ -4,10 +4,11 @@ import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { Memory, createMemoryRouter } from '@ekai/memory'; -import { PORT, MEMORY_DB_PATH, OPENROUTER_API_KEY } from './config.js'; -import { initMemory, fetchMemoryContext, ingestMessages } from './memory-client.js'; +import { PORT, MEMORY_DB_PATH, OPENROUTER_API_KEY, CONVERSATION_LOG_PATH } from './config.js'; +import { initMemory, fetchMemoryContext } from './memory-client.js'; import { formatMemoryBlock, injectMemory } from './memory.js'; import { proxyToOpenRouter } from './proxy.js'; +import { appendConversationLog, normalizeMessages } from './conversation-log.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -60,7 +61,7 @@ app.post('/v1/chat/completions', async (req, res) => { : null; // Save original messages before memory injection mutates them - const originalMessages = body.messages.map((m: any) => ({ ...m })); + const originalMessages = normalizeMessages(body.messages); // Fetch memory context (non-blocking on failure) if (query) { @@ -71,9 +72,12 @@ app.post('/v1/chat/completions', async (req, res) => { } } - // Ingestion disabled — re-ingesting full conversation on every call causes - // runaway memory growth (no dedup). Will re-enable with proper deduplication. - // ingestMessages(originalMessages, profile); + // Capture original conversation for scheduled deduplicated ingestion. + appendConversationLog(CONVERSATION_LOG_PATH, { + agentId, + userId, + messages: originalMessages, + }); await proxyToOpenRouter(body, res, clientKey); } catch (err: any) { diff --git a/memory/src/memory.ts b/memory/src/memory.ts index bef25da..e6eab98 100644 --- a/memory/src/memory.ts +++ b/memory/src/memory.ts @@ -81,7 +81,7 @@ export class Memory { */ async add( messages: Array<{ role: string; content: string }>, - opts?: { userId?: string }, + opts?: { userId?: string; deduplicate?: boolean }, ): Promise<{ stored: number; ids: string[]; filtered?: boolean; reason?: string }> { const agent = this.requireAgent(); @@ -106,6 +106,7 @@ export class Memory { const rows = await this.store.ingest(components, agent, { origin: { originType: 'conversation' }, userId: opts?.userId, + deduplicate: opts?.deduplicate, }); return { stored: rows.length, ids: rows.map((r) => r.id) }; diff --git a/memory/src/router.ts b/memory/src/router.ts index 8136952..f777b52 100644 --- a/memory/src/router.ts +++ b/memory/src/router.ts @@ -94,10 +94,11 @@ export function createMemoryRouter(store: SqliteMemoryStore, extractFn?: Extract }); router.post('/v1/ingest', async (req: Request, res: Response) => { - const { messages, agent, userId } = req.body as { + const { messages, agent, userId, deduplicate } = req.body as { messages?: Array<{ role: 'user' | 'assistant' | string; content: string }>; agent?: string; userId?: string; + deduplicate?: boolean; }; let normalizedAgent: string; @@ -152,6 +153,7 @@ export function createMemoryRouter(store: SqliteMemoryStore, extractFn?: Extract const rows = await store.ingest(finalComponents, normalizedAgent, { origin: { originType: 'conversation', originActor: userId }, userId, + deduplicate, }); res.json({ stored: rows.length, ids: rows.map((r) => r.id), agent: normalizedAgent }); } catch (err: any) { diff --git a/memory/src/sqlite-store.ts b/memory/src/sqlite-store.ts index a89e2e3..3005457 100644 --- a/memory/src/sqlite-store.ts +++ b/memory/src/sqlite-store.ts @@ -52,6 +52,7 @@ export class SqliteMemoryStore { const source = options?.source; const origin = options?.origin; const userId = options?.userId; + const deduplicate = options?.deduplicate ?? true; // Upsert into agent_users when userId is provided if (userId) { @@ -63,7 +64,9 @@ export class SqliteMemoryStore { if (episodic && typeof episodic === 'string' && episodic.trim()) { const embedding = await this.embed(episodic, 'episodic'); - const existingDup = this.findDuplicateMemory(embedding, 'episodic', agentId, 0.9); + const existingDup = deduplicate + ? this.findDuplicateMemory(embedding, 'episodic', agentId, 0.9) + : null; if (existingDup) { if (source && !existingDup.source) { this.setMemorySource(existingDup.id, source); @@ -215,7 +218,9 @@ export class SqliteMemoryStore { const embedding = await this.embed(textToEmbed, 'procedural'); procRow.embedding = embedding; - const existingDup = this.findDuplicateProcedural(embedding, agentId, 0.9); + const existingDup = deduplicate + ? this.findDuplicateProcedural(embedding, agentId, 0.9) + : null; if (existingDup) { if (source && !existingDup.source) { this.setProceduralSource(existingDup.id, source); diff --git a/memory/src/types.ts b/memory/src/types.ts index d1d2506..3ffc0ad 100644 --- a/memory/src/types.ts +++ b/memory/src/types.ts @@ -153,4 +153,5 @@ export interface IngestOptions { source?: string; origin?: MemoryOrigin; userId?: string; + deduplicate?: boolean; } diff --git a/model_catalog/chat_completions_providers_v1.json b/model_catalog/chat_completions_providers_v1.json new file mode 100644 index 0000000..b31f2fb --- /dev/null +++ b/model_catalog/chat_completions_providers_v1.json @@ -0,0 +1,199 @@ +{ + "providers": [ + { + "provider": "openai", + "models": [ + "gpt-5.2", + "gpt-5.2-pro", + "gpt-5.1", + "gpt-5.1-codex", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5-chat-latest", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-2024-05-13", + "gpt-4o-mini", + "gpt-4o-mini-4k", + "gpt-4o-mini-8k", + "gpt-realtime", + "gpt-4o-realtime-preview", + "gpt-4o-mini-realtime-preview", + "o1", + "o1-pro", + "o3-pro", + "o3", + "o3-deep-research", + "o4-mini", + "o4-mini-deep-research", + "o3-mini", + "o1-mini", + "gpt-4o-mini-search-preview", + "gpt-4o-search-preview", + "computer-use-preview", + "gpt-image-1", + "gpt-3.5-turbo", + "chatgpt-4o-latest", + "gpt-4-turbo-2024-04-09", + "gpt-4-0125-preview", + "gpt-4-1106-preview", + "gpt-4-1106-vision-preview", + "gpt-4-0613", + "gpt-4-0314", + "gpt-4-32k", + "gpt-3.5-turbo-0125", + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-0613", + "gpt-3.5-0301", + "gpt-3.5-turbo-instruct", + "gpt-3.5-turbo-16k-0613" + ], + "chat_completions": { + "base_url": "https://api.openai.com/v1/chat/completions", + "auth": { + "env_var": "OPENAI_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "supported_client_formats": ["openai"], + "payload_defaults": { + "stream_options": { + "include_usage": true + } + }, + "usage": { + "format": "openai_chat" + } + } + }, + { + "provider": "google", + "models": [ + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-3.0-pro-preview" + ], + "chat_completions": { + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + "auth": { + "env_var": "GOOGLE_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "supported_client_formats": ["openai"], + "payload_defaults": { + "stream_options": { + "include_usage": true + } + }, + "usage": { + "format": "openai_chat" + } + } + }, + { + "provider": "xAI", + "models": [ + "grok-4", + "grok-3", + "grok-3-mini", + "grok-code-fast-1", + "grok-code-fast", + "grok-code-fast-1-0825" + ], + "chat_completions": { + "base_url": "https://api.x.ai/v1/chat/completions", + "auth": { + "env_var": "XAI_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "supported_client_formats": ["openai"], + "payload_defaults": { + "stream_options": { + "include_usage": true + } + }, + "usage": { + "format": "openai_chat" + } + } + }, + { + "provider": "openrouter", + "models": [ + "anthropic/claude-haiku-4.5", + "openai/gpt-4o", + "openai/gpt-5-pro", + "x-ai/grok-code-fast-1", + "google/gemini-2.0-flash-001", + "google/gemini-2.5-pro", + "deepseek/deepseek-chat-v3.1", + "z-ai/glm-4.5" + ], + "chat_completions": { + "base_url": "https://openrouter.ai/api/v1/chat/completions", + "auth": { + "env_var": "OPENROUTER_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "static_headers": { + "HTTP-Referer": "https://ekailabs.xyz", + "X-Title": "Ekai Gateway" + }, + "supported_client_formats": ["openai"], + "payload_defaults": { + "stream_options": { + "include_usage": true + } + }, + "usage": { + "format": "openai_chat" + } + } + }, + { + "provider": "ollama", + "models": [ + "llama3.3", + "llama3.2", + "llama3.1", + "llama3", + "gemma3", + "gemma2", + "qwen3", + "qwen2.5-coder", + "deepseek-r1", + "deepseek-coder-v2", + "phi4", + "phi3", + "mistral", + "mixtral", + "codellama", + "starcoder2" + ], + "chat_completions": { + "base_url": "http://localhost:11434/v1/chat/completions", + "auth": { + "env_var": "OLLAMA_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "supported_client_formats": ["openai"], + "payload_defaults": { + "stream_options": { + "include_usage": true + } + }, + "usage": { + "format": "openai_chat" + } + } + } + ] +} diff --git a/model_catalog/responses_providers_v1.json b/model_catalog/responses_providers_v1.json new file mode 100644 index 0000000..472f6b8 --- /dev/null +++ b/model_catalog/responses_providers_v1.json @@ -0,0 +1,50 @@ +{ + "providers": [ + { + "provider": "openai", + "models": [ + "gpt-5-codex", + "codex-mini-latest" + ], + "responses": { + "base_url": "https://api.openai.com/v1/responses", + "auth": { + "env_var": "OPENAI_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "supported_client_formats": ["openai_responses"] + } + }, + { + "provider": "ollama", + "models": [ + "llama3.3", + "llama3.2", + "llama3.1", + "llama3", + "gemma3", + "gemma2", + "qwen3", + "qwen2.5-coder", + "deepseek-r1", + "deepseek-coder-v2", + "phi4", + "phi3", + "mistral", + "mixtral", + "codellama", + "starcoder2" + ], + "responses": { + "base_url": "http://localhost:11434/v1/responses", + "auth": { + "env_var": "OLLAMA_API_KEY", + "header": "Authorization", + "scheme": "Bearer" + }, + "supported_client_formats": ["openai_responses"] + } + } + ] +} diff --git a/package.json b/package.json index 6a635ac..ce7ea2b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test": "npm run test --workspace=@ekai/openrouter", "dev:ui": "npm run dev --workspace=ui/dashboard", "dev:openrouter": "npm run dev --workspace=@ekai/openrouter", - "start:ui": "npx --workspace=ui/dashboard next start -p 3000 -H 0.0.0.0" + "start:ui": "npx --workspace=ui/dashboard next start -p 3000 -H 0.0.0.0", + "ingest:from-log": "node scripts/ingest-from-log.mjs" }, "devDependencies": { "concurrently": "^8.2.2" diff --git a/scripts/ingest-from-log.mjs b/scripts/ingest-from-log.mjs new file mode 100644 index 0000000..442cfbb --- /dev/null +++ b/scripts/ingest-from-log.mjs @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const conversationLogPath = path.resolve( + process.env.CONVERSATION_LOG_PATH ?? './data/conversation-log.jsonl', +); +const checkpointPath = path.resolve( + process.env.INGEST_CHECKPOINT_PATH ?? './data/ingest-checkpoint.json', +); +const memoryIngestUrl = new URL( + '/v1/ingest', + process.env.MEMORY_INGEST_URL ?? `http://localhost:${process.env.OPENROUTER_PORT || '4010'}`, +).toString(); +const rateLimitMs = Number(process.env.INGEST_RATE_LIMIT_MS ?? 1000); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function readCheckpoint() { + try { + const raw = await readFile(checkpointPath, 'utf8'); + const parsed = JSON.parse(raw); + return { + lastTs: Number(parsed.lastTs) || 0, + lastId: typeof parsed.lastId === 'string' ? parsed.lastId : '', + }; + } catch { + return { lastTs: 0, lastId: '' }; + } +} + +async function writeCheckpoint(nextCheckpoint) { + await mkdir(path.dirname(checkpointPath), { recursive: true }); + const payload = `${JSON.stringify(nextCheckpoint, null, 2)}\n`; + await writeFile(checkpointPath, payload, 'utf8'); +} + +function parseLogLines(raw) { + return raw + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((entry) => entry && Array.isArray(entry.messages) && typeof entry.ts === 'number') + .map((entry) => ({ + id: typeof entry.id === 'string' ? entry.id : '', + ts: entry.ts, + agentId: typeof entry.agentId === 'string' && entry.agentId ? entry.agentId : 'default', + userId: typeof entry.userId === 'string' ? entry.userId : undefined, + messages: entry.messages + .map((m) => ({ + role: typeof m?.role === 'string' ? m.role : 'user', + content: typeof m?.content === 'string' ? m.content.trim() : '', + })) + .filter((m) => m.content.length > 0), + })) + .filter((entry) => entry.messages.length > 0); +} + +function isAfterCheckpoint(entry, checkpoint) { + if (entry.ts > checkpoint.lastTs) return true; + if (entry.ts < checkpoint.lastTs) return false; + return entry.id > checkpoint.lastId; +} + +async function ingestOne(entry) { + const response = await fetch(memoryIngestUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agent: entry.agentId, + userId: entry.userId, + messages: entry.messages, + deduplicate: true, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`ingest failed (${response.status}): ${text}`); + } +} + +async function main() { + if (!fs.existsSync(conversationLogPath)) { + console.log(`[ingest-from-log] no log file at ${conversationLogPath}`); + return; + } + + const checkpoint = await readCheckpoint(); + const rawLog = await readFile(conversationLogPath, 'utf8'); + const entries = parseLogLines(rawLog) + .filter((entry) => isAfterCheckpoint(entry, checkpoint)) + .sort((a, b) => (a.ts === b.ts ? a.id.localeCompare(b.id) : a.ts - b.ts)); + + if (!entries.length) { + console.log('[ingest-from-log] no new conversations'); + return; + } + + let processed = 0; + let lastCheckpoint = checkpoint; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + await ingestOne(entry); + lastCheckpoint = { lastTs: entry.ts, lastId: entry.id }; + await writeCheckpoint(lastCheckpoint); + processed += 1; + + if (index < entries.length - 1) { + await sleep(rateLimitMs); + } + } + + console.log(`[ingest-from-log] processed ${processed} conversations`); +} + +main().catch((err) => { + console.error(`[ingest-from-log] ${err.message}`); + process.exitCode = 1; +}); diff --git a/shared/types/types.ts b/shared/types/types.ts new file mode 100644 index 0000000..c98df8f --- /dev/null +++ b/shared/types/types.ts @@ -0,0 +1,71 @@ +export interface ChatCompletionRequest { + messages: Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + }>; + model: string; + stream?: boolean; + temperature?: number; + max_tokens?: number; + [key: string]: any; +} + +export interface ChatCompletionResponse { + id: string; + object: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: { + role: string; + content: string; + }; + finish_reason: string; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + + +export type ProviderName = 'openai' | 'openrouter' | 'anthropic' | 'ollama'; + +// Removed conversation types - no conversation storage + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface AnthropicMessagesRequest { + model: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string | Array<{ type: string; text: string; }>; + }>; + max_tokens?: number; // Make optional since Claude Code might not send it + system?: string | Array<{ type: string; text: string; }>; // Can be string or array + temperature?: number; + stream?: boolean; + [key: string]: any; +} + +export interface AnthropicMessagesResponse { + id: string; + type: 'message'; + role: 'assistant'; + content: Array<{ + type: 'text'; + text: string; + }>; + model: string; + stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use'; + stop_sequence?: string; + usage: { + input_tokens: number; + output_tokens: number; + }; +} \ No newline at end of file