diff --git a/src/components/proxy/advanced/ToolsTab.tsx b/src/components/proxy/advanced/ToolsTab.tsx index c50ccaf..0370599 100644 --- a/src/components/proxy/advanced/ToolsTab.tsx +++ b/src/components/proxy/advanced/ToolsTab.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueries } from '@tanstack/react-query'; import { ipc } from '@/ipc/manager'; @@ -6,17 +6,42 @@ import { ipc } from '@/ipc/manager'; import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/use-toast'; import { StatCard } from '@/components/usage/StatCard'; -import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react'; +import { + Loader2, + Copy, + CheckCircle, + ShieldAlert, + XCircle, + Terminal, + Code2, + Braces, + Sparkles, +} from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; const IDE_CONFIGS = [ + { key: 'opencode', label: 'OpenCode', icon: Sparkles }, { key: 'vscode', label: 'VS Code', icon: Code2 }, { key: 'cursor', label: 'Cursor', icon: Terminal }, { key: 'claude-code', label: 'Claude Code', icon: Braces }, ] as const; -export const ToolsTab: React.FC = () => { +interface ToolsTabProps { + availableModelIds?: string[]; +} + +export const ToolsTab: React.FC = ({ + availableModelIds = ['gemini-3.1-pro-high'], +}) => { const { t } = useTranslation(); const { toast } = useToast(); + const [selectedModel, setSelectedModel] = useState(availableModelIds[0]); const { data: parityData, isLoading: parityLoading } = useQuery({ queryKey: ['proxyAdvanced', 'parityCounters'], @@ -25,16 +50,11 @@ export const ToolsTab: React.FC = () => { }); const ideQueries = useQueries({ - queries: [ - { - queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'], - queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }), - }, - { - queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'], - queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }), - }, - ], + queries: IDE_CONFIGS.map(({ key }) => ({ + queryKey: ['proxyAdvanced', 'ideConfig', key, selectedModel], + queryFn: () => + ipc.client.proxyAdvanced.generateIdeConfig({ ide: key, defaultModel: selectedModel }), + })), }); const parity = parityData?.success ? parityData.data : undefined; @@ -44,15 +64,21 @@ export const ToolsTab: React.FC = () => { toast({ title: t('proxy.advanced.tools.copyConfig') }); }; - const getIdeInstructions = (key: string): string => { + const getIdeInstructions = (key: string, index: number): string => { if (key === 'claude-code') { return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY'; } - const index = key === 'vscode' ? 0 : 1; - const result = ideQueries[index].data; + const result = ideQueries[index]?.data; if (result?.success && result.data) { - const d = result.data; - return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const d = result.data as any; + if (d.format === 'json') { + return JSON.stringify(d.content, null, 2); + } + if (d.format === 'env' || d.format === 'shell') { + return typeof d.content === 'string' ? d.content : JSON.stringify(d.content, null, 2); + } + return `${d.instructions || ''}\n${d.proxySetting || 'URL'}: http://localhost:8045\n${d.apiKeySetting || 'Key'}: YOUR_API_KEY`; } return ''; }; @@ -87,24 +113,45 @@ export const ToolsTab: React.FC = () => { {/* IDE Quick Setup */} -
-
-

{t('proxy.advanced.tools.ideSetup')}

-

Quick configuration for popular IDEs

+
+
+
+

{t('proxy.advanced.tools.ideSetup')}

+

Quick configuration for popular IDEs

+
+
+ Default Model: + +
-
+
{isIdeLoading ? (
) : ( - IDE_CONFIGS.map(({ key, label, icon: Icon }) => { - const instructions = getIdeInstructions(key); + IDE_CONFIGS.map(({ key, label, icon: Icon }, index) => { + const instructions = getIdeInstructions(key, index); + const resultData = ideQueries[index]?.data; + const hasError = resultData && !resultData.success; + const errorMsg = resultData?.error || ''; + return (
- + {label}
- {instructions && ( -
+                  {hasError && (
+                    
+ {errorMsg} +
+ )} + {instructions && !hasError && ( +
                       {instructions}
                     
)} @@ -128,7 +181,6 @@ export const ToolsTab: React.FC = () => { )}
-
); }; diff --git a/src/ipc/proxy-advanced/handler.ts b/src/ipc/proxy-advanced/handler.ts index 6a8fe42..2dbf331 100644 --- a/src/ipc/proxy-advanced/handler.ts +++ b/src/ipc/proxy-advanced/handler.ts @@ -1,8 +1,13 @@ import { logger } from '../../utils/logger'; -import { getServiceOrThrow } from './service-registry'; -import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service'; +import { getServiceOrThrow, getServiceOptional } from './service-registry'; +import type { + IdeConfigResult, + SupportedIde, +} from '../../server/modules/proxy/proxy-ide-config.service'; import { TrafficLogsRepo } from '../database/proxyMetricsHandler'; import { CloudAccountRepo } from '../database/cloudHandler'; +import { ConfigManager } from '../config/manager'; +import { networkInterfaces } from 'os'; export interface TrafficLogEntryParsed { timestamp: string; @@ -84,7 +89,15 @@ export async function exportTrafficLogs( } // CSV format - const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs']; + const headers = [ + 'timestamp', + 'direction', + 'requestId', + 'endpoint', + 'method', + 'status', + 'durationMs', + ]; const rowsCsv = rows.map((e) => [ new Date(e.timestamp).toISOString(), @@ -108,11 +121,27 @@ export async function exportTrafficLogs( export async function getParityCounters(): Promise<{ success: boolean; - data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string }; + data?: { + totalRequests: number; + matchedRequests: number; + parityViolations: number; + lastUpdated: string; + }; error?: string; }> { try { - const tokenManager = getServiceOrThrow('tokenManager'); + const tokenManager = getServiceOptional('tokenManager'); + if (!tokenManager) { + return { + success: true, + data: { + totalRequests: 0, + matchedRequests: 0, + parityViolations: 0, + lastUpdated: new Date().toISOString(), + }, + }; + } return { success: true, data: tokenManager.getParityCounters(), @@ -124,14 +153,24 @@ export async function getParityCounters(): Promise<{ export async function getCircuitBreakerStatus(): Promise<{ success: boolean; - data?: { states: Record; globalEnabled: boolean }; + data?: { + states: Record; + globalEnabled: boolean; + }; error?: string; }> { try { - const tokenManager = getServiceOrThrow('tokenManager'); + const tokenManager = getServiceOptional('tokenManager'); + if (!tokenManager) { + return { + success: true, + data: { states: {}, globalEnabled: true }, + }; + } const rawStatus = tokenManager.getCircuitBreakerStatus(); - const states: Record = {}; + const states: Record = + {}; for (const [accountId, entry] of Object.entries(rawStatus)) { states[accountId] = { state: entry.state, @@ -154,7 +193,7 @@ export async function getCircuitBreakerStatus(): Promise<{ export async function getProxyMetrics(): Promise<{ success: boolean; - data?: { + data?: { uptimeSeconds: number; totalRequests: number; activeConnections: number; @@ -166,7 +205,21 @@ export async function getProxyMetrics(): Promise<{ error?: string; }> { try { - const proxyMetrics = getServiceOrThrow('proxyMetrics'); + const proxyMetrics = getServiceOptional('proxyMetrics'); + if (!proxyMetrics) { + return { + success: true, + data: { + uptimeSeconds: 0, + totalRequests: 0, + activeConnections: 0, + avgLatency: 0, + errorRate: 0, + requestsPerMinute: 0, + cacheHitRate: 0, + }, + }; + } const metrics = proxyMetrics.getMetrics(); return { @@ -187,9 +240,14 @@ export async function getProxyMetrics(): Promise<{ } } -export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> { +export async function getRecentRequests(): Promise<{ + success: boolean; + data?: unknown[]; + error?: string; +}> { try { - const proxyReplay = getServiceOrThrow('proxyReplay'); + const proxyReplay = getServiceOptional('proxyReplay'); + if (!proxyReplay) return { success: true, data: [] }; return { success: true, data: proxyReplay.getRecentRequests(20), @@ -201,7 +259,11 @@ export async function getRecentRequests(): Promise<{ success: boolean; data?: un export async function replayRequest( requestId: string, -): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> { +): Promise<{ + success: boolean; + data?: { original: unknown; newResponse: unknown }; + error?: string; +}> { try { const proxyReplay = getServiceOrThrow('proxyReplay'); const result = await proxyReplay.replayRequest(requestId); @@ -278,14 +340,14 @@ export async function getModelCapabilities(): Promise<{ } } -function mapIdeConfigResult( - result: IdeConfigResult, -): { +function mapIdeConfigResult(result: IdeConfigResult): { name: string; settingsPath: string; - proxySetting: string; - apiKeySetting: string; - instructions: string; + proxySetting?: string; + apiKeySetting?: string; + instructions?: string; + format: 'json' | 'shell' | 'env' | 'instructions'; + content: Record | string; } { switch (result.ide) { case 'vscode': @@ -295,6 +357,8 @@ function mapIdeConfigResult( proxySetting: 'openai.url', apiKeySetting: 'openai.apiKey', instructions: 'Add these settings to your VS Code settings.json', + format: result.format, + content: result.content as Record, }; case 'cursor': return { @@ -303,6 +367,8 @@ function mapIdeConfigResult( proxySetting: 'OPENAI_BASE_URL', apiKeySetting: 'OPENAI_API_KEY', instructions: 'Set these environment variables for Cursor', + format: result.format, + content: result.content as Record, }; case 'claude-code': return { @@ -311,6 +377,8 @@ function mapIdeConfigResult( proxySetting: 'ANTHROPIC_BASE_URL', apiKeySetting: 'ANTHROPIC_API_KEY', instructions: 'Run these export commands before starting Claude Code', + format: result.format, + content: result.content as string, }; case 'jetbrains': return { @@ -319,6 +387,8 @@ function mapIdeConfigResult( proxySetting: 'Base URL', apiKeySetting: 'API Key', instructions: String(result.content), + format: result.format, + content: result.content as string, }; case 'opencide': return { @@ -327,9 +397,19 @@ function mapIdeConfigResult( proxySetting: 'OPENAI_BASE_URL', apiKeySetting: 'OPENAI_API_KEY', instructions: 'Add these lines to your .env file', + format: result.format, + content: result.content as string, + }; + case 'opencode': + return { + name: 'OpenCode', + settingsPath: '.config/opencode/providers.json', + instructions: 'Add this snippet to your providers.json', + format: result.format, + content: result.content as Record, }; default: { - const exhaustiveCheck: never = result.ide; + const exhaustiveCheck: never = result.ide as never; throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`); } } @@ -337,29 +417,121 @@ function mapIdeConfigResult( export async function generateIdeConfig( ide: string, + defaultModel?: string, ): Promise<{ success: boolean; data?: { name: string; settingsPath: string; - proxySetting: string; - apiKeySetting: string; - instructions: string; + proxySetting?: string; + apiKeySetting?: string; + instructions?: string; + format: 'json' | 'shell' | 'env' | 'instructions'; + content: Record | string; }; error?: string; }> { try { - const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig'); - - const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const; - const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number]; + const supportedIdes = [ + 'cursor', + 'vscode', + 'claude-code', + 'jetbrains', + 'opencide', + 'opencode', + ] as const; + const normalizedIde = ide.toLowerCase() as (typeof supportedIdes)[number]; if (!supportedIdes.includes(normalizedIde)) { return { success: false, error: `Unsupported IDE: ${ide}` }; } - const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde); - if (!result) { - return { success: false, error: 'Failed to generate IDE config: server not configured' }; + // Attempt to use NestJS service if running, fallback to manual generation + const proxyIdeConfig = getServiceOptional('proxyIdeConfig'); + if (proxyIdeConfig) { + const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde, defaultModel); + if (result) return { success: true, data: mapIdeConfigResult(result) }; + } + + // Fallback logic when proxy is stopped + const config = ConfigManager.getCachedConfig()?.proxy; + const port = config?.port || 8045; + const apiKey = config?.api_key || 'YOUR_API_KEY'; + + let localIp = '127.0.0.1'; + const interfaces = networkInterfaces(); + for (const iface of Object.values(interfaces)) { + if (iface) { + for (const entry of iface) { + if (entry.family === 'IPv4' && !entry.internal) { + localIp = entry.address; + break; + } + } + } + } + + // Fallback generator mimicking the proxyIdeConfig.service + const baseUrl = `http://${localIp}:${port}/v1`; + let result: IdeConfigResult; + + switch (normalizedIde) { + case 'cursor': + result = { + ide: normalizedIde, + format: 'json', + content: { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl }, + }; + break; + case 'vscode': + result = { + ide: normalizedIde, + format: 'json', + content: { 'openai.url': baseUrl, 'openai.apiKey': apiKey }, + }; + break; + case 'claude-code': + result = { + ide: normalizedIde, + format: 'shell', + content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`, + }; + break; + case 'jetbrains': + result = { + ide: normalizedIde, + format: 'instructions', + content: [ + 'JetBrains AI Assistant Setup:', + '1. Open Settings (File > Settings or Ctrl+Alt+S).', + '2. Navigate to Languages & Frameworks > AI Assistant.', + '3. Under "Custom OpenAI Endpoint", set:', + ` - Base URL: ${baseUrl}`, + ` - API Key: ${apiKey}`, + '4. Click "Apply" and restart the IDE if prompted.', + ].join('\n'), + }; + break; + case 'opencide': + result = { + ide: normalizedIde, + format: 'env', + content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`, + }; + break; + case 'opencode': + result = { + ide: normalizedIde, + format: 'json', + content: { + apiType: 'openai', + apiKey: apiKey, + baseURL: baseUrl, + models: { chat: [defaultModel || 'gemini-3.1-pro-high'] }, + }, + }; + break; + default: + throw new Error('Unsupported'); } return { diff --git a/src/ipc/proxy-advanced/router.ts b/src/ipc/proxy-advanced/router.ts index 32e74a0..b81c478 100644 --- a/src/ipc/proxy-advanced/router.ts +++ b/src/ipc/proxy-advanced/router.ts @@ -155,9 +155,11 @@ const IdeConfigResponseSchema = z.object({ .object({ name: z.string(), settingsPath: z.string(), - proxySetting: z.string(), - apiKeySetting: z.string(), - instructions: z.string(), + proxySetting: z.string().optional(), + apiKeySetting: z.string().optional(), + instructions: z.string().optional(), + format: z.enum(['json', 'shell', 'env', 'instructions']).optional(), + content: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(), }) .optional(), error: z.string().optional(), @@ -204,10 +206,10 @@ export const proxyAdvancedRouter = os.router({ }), generateIdeConfig: os - .input(z.object({ ide: z.string() })) + .input(z.object({ ide: z.string(), defaultModel: z.string().optional() })) .output(IdeConfigResponseSchema) .handler(async ({ input }) => { - return generateIdeConfig(input.ide); + return generateIdeConfig(input.ide, input.defaultModel); }), replayRequest: os diff --git a/src/ipc/proxy-advanced/service-registry.ts b/src/ipc/proxy-advanced/service-registry.ts index d7a6aef..1ee4175 100644 --- a/src/ipc/proxy-advanced/service-registry.ts +++ b/src/ipc/proxy-advanced/service-registry.ts @@ -37,3 +37,9 @@ export function getServiceOrThrow( if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`); return svc; } + +export function getServiceOptional( + key: K, +): ProxyAdvancedRegistry[K] { + return proxyAdvancedRegistry[key]; +} diff --git a/src/server/modules/proxy/proxy-ide-config.service.ts b/src/server/modules/proxy/proxy-ide-config.service.ts index ef4b9fa..3dda9f8 100644 --- a/src/server/modules/proxy/proxy-ide-config.service.ts +++ b/src/server/modules/proxy/proxy-ide-config.service.ts @@ -3,12 +3,18 @@ import { networkInterfaces } from 'os'; import { getServerConfig } from '../../server-config'; import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry'; -export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide'; +export type SupportedIde = + | 'cursor' + | 'vscode' + | 'claude-code' + | 'jetbrains' + | 'opencide' + | 'opencode'; export interface IdeConfigResult { ide: SupportedIde; format: 'json' | 'shell' | 'env' | 'instructions'; - content: Record | string; + content: Record | string; } @Injectable() @@ -46,6 +52,7 @@ export class ProxyIdeConfigService { port: number, apiKey: string, localIp: string, + defaultModel: string = 'gemini-3.1-pro-high', ): IdeConfigResult { const baseUrl = `http://${localIp}:${port}/v1`; @@ -99,6 +106,20 @@ export class ProxyIdeConfigService { content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`, }; } + case 'opencode': { + return { + ide, + format: 'json', + content: { + apiType: 'openai', + apiKey: apiKey, + baseURL: baseUrl, + models: { + chat: [defaultModel], + }, + }, + }; + } default: { // Exhaustive fallback to satisfy TypeScript const exhaustiveCheck: never = ide; @@ -112,7 +133,7 @@ export class ProxyIdeConfigService { * Convenience helper that reads the current server config and local IP, * then delegates to generateIdeConfig. */ - generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null { + generateConfigForCurrentServer(ide: SupportedIde, defaultModel?: string): IdeConfigResult | null { const config = getServerConfig(); if (!config) { this.logger.warn('Server config not available; cannot generate IDE config'); @@ -121,6 +142,6 @@ export class ProxyIdeConfigService { const port = config.port; const apiKey = config.api_key; const localIp = this.getLocalIp(); - return this.generateIdeConfig(ide, port, apiKey, localIp); + return this.generateIdeConfig(ide, port, apiKey, localIp, defaultModel); } }