feat(proxy): add OpenCode IDE config and fix service availability

- Add opencode to SupportedIde with JSON providers.json output
- Add defaultModel parameter for IDE config generation
- Add model selector dropdown in ToolsTab for IDE configurations
- Use getServiceOptional for graceful degradation when proxy is stopped
- Add fallback IDE config generation using ConfigManager when NestJS is offline
- Update IPC router schema to accept defaultModel parameter
This commit is contained in:
Gemini Nexus Bot committed 2026-05-02 00:30:18 -03:00
1 parent 641c739b64
commit 713c4497c6
5 files changed
+321 -68

No files matched your search

+81 -29
View File
@@ -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<ToolsTabProps> = ({
availableModelIds = ['gemini-3.1-pro-high'],
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const [selectedModel, setSelectedModel] = useState<string>(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 = () => {
</div>
{/* IDE Quick Setup */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
<p className="text-xs text-muted-foreground">Quick configuration for popular IDEs</p>
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<div className="space-y-1">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
<p className="text-muted-foreground text-xs">Quick configuration for popular IDEs</p>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Default Model:</span>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModelIds.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="p-5 space-y-3">
<div className="space-y-3 p-5">
{isIdeLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
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 (
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<Icon className="text-muted-foreground h-4 w-4" />
<span className="text-[13px] font-medium">{label}</span>
</div>
<Button
@@ -112,13 +159,19 @@ export const ToolsTab: React.FC = () => {
size="sm"
className="h-8 text-xs"
onClick={() => copyToClipboard(instructions)}
disabled={!instructions || hasError}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{t('proxy.advanced.tools.copyConfig')}
</Button>
</div>
{instructions && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
{hasError && (
<div className="bg-card border-t border-white/[0.06] p-3 font-mono text-xs text-amber-500/80">
{errorMsg}
</div>
)}
{instructions && !hasError && (
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{instructions}
</pre>
)}
@@ -128,7 +181,6 @@ export const ToolsTab: React.FC = () => {
)}
</div>
</div>
</div>
);
};
+202 -30
View File
@@ -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<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
data?: {
states: Record<string, { state: string; failures: number; lastFailure: string | null }>;
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<string, { state: string; failures: number; lastFailure: string | null }> = {};
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> =
{};
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, unknown> | 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<string, unknown>,
};
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<string, unknown>,
};
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<string, unknown>,
};
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, unknown> | 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 {
+7 -5
View File
@@ -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
@@ -37,3 +37,9 @@ export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
return svc;
}
export function getServiceOptional<K extends keyof ProxyAdvancedRegistry>(
key: K,
): ProxyAdvancedRegistry[K] {
return proxyAdvancedRegistry[key];
}
@@ -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, string> | string;
content: Record<string, any> | 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);
}
}