From 569898ad5f0072afe711681687bf316f0b62f028 Mon Sep 17 00:00:00 2001 From: tr-varlg Date: Sat, 7 Mar 2026 05:05:18 +0300 Subject: [PATCH] CLI providers (Codex, Gemini, Claude) have been added as providers with user interface integration. --- packages/core/data/providers/openai.json | 37 +- packages/gateway/src/routes/agent-service.ts | 64 +- .../gateway/src/routes/extensions/audit.ts | 62 +- .../gateway/src/routes/extensions/eval.ts | 48 +- .../src/routes/extensions/generation.ts | 92 +-- packages/gateway/src/routes/models.test.ts | 10 + packages/gateway/src/routes/models.ts | 41 +- packages/gateway/src/routes/providers.test.ts | 7 + packages/gateway/src/routes/providers.ts | 212 ++++++- packages/gateway/src/routes/settings.test.ts | 33 + packages/gateway/src/routes/settings.ts | 62 +- .../gateway/src/routes/workflow-copilot.ts | 44 +- .../gateway/src/services/binary-utils.test.ts | 27 + packages/gateway/src/services/binary-utils.ts | 21 + .../src/services/coding-agent-service.test.ts | 10 +- .../src/services/coding-agent-service.ts | 38 +- .../gateway/src/services/model-execution.ts | 564 ++++++++++++++++++ .../src/services/workflow/node-executors.ts | 52 +- packages/ui/src/api/endpoints/index.ts | 2 +- packages/ui/src/api/endpoints/misc.ts | 16 + packages/ui/src/api/endpoints/providers.ts | 6 + packages/ui/src/components/ProvidersTab.tsx | 307 +++++++++- .../src/components/ai-models/AIModelsTab.tsx | 8 +- packages/ui/src/pages/ApiKeysPage.tsx | 43 +- .../ui/src/pages/CodingAgentSettingsPage.tsx | 15 +- packages/ui/src/pages/CodingAgentsPage.tsx | 6 +- packages/ui/src/pages/ModelsPage.tsx | 10 +- packages/ui/src/types/models.ts | 16 +- 28 files changed, 1493 insertions(+), 360 deletions(-) create mode 100644 packages/gateway/src/services/model-execution.ts diff --git a/packages/core/data/providers/openai.json b/packages/core/data/providers/openai.json index 8f4bce17..e668ad2c 100644 --- a/packages/core/data/providers/openai.json +++ b/packages/core/data/providers/openai.json @@ -11,6 +11,41 @@ "systemMessage": true }, "models": [ + { + "id": "gpt-5.4", + "name": "GPT-5.4", + "contextWindow": 1050000, + "maxOutput": 128000, + "inputPrice": 2.5, + "outputPrice": 15, + "capabilities": [ + "chat", + "vision", + "function_calling", + "json_mode", + "reasoning", + "streaming" + ], + "default": true, + "releaseDate": "2026-03-05" + }, + { + "id": "gpt-5.4-pro", + "name": "GPT-5.4 Pro", + "contextWindow": 1050000, + "maxOutput": 128000, + "inputPrice": 30, + "outputPrice": 180, + "capabilities": [ + "chat", + "vision", + "function_calling", + "reasoning", + "streaming" + ], + "default": false, + "releaseDate": "2026-03-05" + }, { "id": "gpt-5.3-codex", "name": "GPT-5.3 Codex", @@ -26,7 +61,7 @@ "reasoning", "streaming" ], - "default": true, + "default": false, "releaseDate": "2026-02-05" }, { diff --git a/packages/gateway/src/routes/agent-service.ts b/packages/gateway/src/routes/agent-service.ts index 116c9404..6b24174e 100644 --- a/packages/gateway/src/routes/agent-service.ts +++ b/packages/gateway/src/routes/agent-service.ts @@ -20,9 +20,6 @@ import { injectMemoryIntoPrompt, unsafeToolId, getBaseName, - createProvider, - createFallbackProvider, - type ProviderConfig, buildSoulPrompt, } from '@ownpilot/core'; import type { SessionInfo } from '../types/index.js'; @@ -81,6 +78,7 @@ import { MAX_AGENT_CACHE_SIZE, MAX_CHAT_AGENT_CACHE_SIZE, } from './agent-cache.js'; +import { createRuntimeProvider, isCliRuntimeProvider } from '../services/model-execution.js'; import { AGENT_DEFAULT_MAX_TOKENS, AGENT_DEFAULT_TEMPERATURE, @@ -115,7 +113,7 @@ async function createAgentFromRecord(record: AgentRecord): Promise { } const apiKey = await getProviderApiKey(resolvedProvider); - if (!apiKey) { + if (!apiKey && !isCliRuntimeProvider(resolvedProvider)) { throw new Error(`API key not configured for provider: ${resolvedProvider}`); } @@ -249,7 +247,7 @@ async function createAgentFromRecord(record: AgentRecord): Promise { systemPrompt: enhancedPrompt, provider: { provider: providerType as AIProvider, - apiKey, + apiKey: apiKey ?? '', baseUrl, headers: providerConfig?.headers, }, @@ -264,7 +262,8 @@ async function createAgentFromRecord(record: AgentRecord): Promise { requestApproval: approvalCallback, }; - const agent = createAgent(config, { tools }); + const providerInstance = await createRuntimeProvider(resolvedProvider); + const agent = createAgent(config, { tools, provider: providerInstance ?? undefined }); // Evict oldest entry if cache is at capacity if (agentCache.size >= MAX_AGENT_CACHE_SIZE) { @@ -426,7 +425,7 @@ async function createChatAgentInstance( fallback?: { provider: string; model: string } ): Promise { const apiKey = await getProviderApiKey(provider); - if (!apiKey) { + if (!apiKey && !isCliRuntimeProvider(provider)) { throw new Error(`API key not configured for provider: ${provider}`); } @@ -506,7 +505,7 @@ async function createChatAgentInstance( systemPrompt: enhancedPrompt, provider: { provider: providerType as AIProvider, - apiKey, + apiKey: apiKey ?? '', baseUrl, headers: providerConfig?.headers, }, @@ -522,37 +521,11 @@ async function createChatAgentInstance( memory: { maxTokens: memoryMaxTokens }, }; - // Build FallbackProvider if a backup model is configured let providerInstance: IProvider | undefined; - if (fallback) { - try { - const fbApiKey = await getProviderApiKey(fallback.provider); - if (fbApiKey) { - const fbConfig = loadProviderConfig(fallback.provider); - const fbType = NATIVE_PROVIDERS.has(fallback.provider) ? fallback.provider : 'openai'; - providerInstance = createFallbackProvider({ - primary: { - provider: providerType as AIProvider, - apiKey, - baseUrl, - headers: providerConfig?.headers, - }, - fallbacks: [ - { - provider: fbType as AIProvider, - apiKey: fbApiKey, - baseUrl: fbConfig?.baseUrl, - headers: fbConfig?.headers, - }, - ], - onFallback: (failed, error, next) => { - log.warn(`Fallback triggered: ${String(failed)} -> ${String(next)}: ${error.message}`); - }, - }); - } - } catch (fbErr) { - log.warn(`Failed to build fallback provider: ${String(fbErr)}`); - } + try { + providerInstance = (await createRuntimeProvider(provider, fallback?.provider)) ?? undefined; + } catch (fbErr) { + log.warn(`Failed to build runtime provider: ${String(fbErr)}`); } if (chatAgentCache.size >= MAX_CHAT_AGENT_CACHE_SIZE) { @@ -732,20 +705,15 @@ export async function compactContext( const summaryPrompt = `Summarize the following conversation history into a concise summary (max 200 words). Focus on key topics discussed, decisions made, and important context needed to continue the conversation naturally:\n\n${conversationText}`; const apiKey = await getProviderApiKey(provider); - if (!apiKey) { + if (!apiKey && !isCliRuntimeProvider(provider)) { return { compacted: false, removedMessages: 0, newTokenEstimate: 0 }; } - const providerConfig = loadProviderConfig(provider); - const providerType = NATIVE_PROVIDERS.has(provider) ? provider : 'openai'; - try { - const summaryProvider = createProvider({ - provider: providerType as ProviderConfig['provider'], - apiKey, - baseUrl: providerConfig?.baseUrl, - headers: providerConfig?.headers, - }); + const summaryProvider = await createRuntimeProvider(provider); + if (!summaryProvider) { + return { compacted: false, removedMessages: 0, newTokenEstimate: 0 }; + } const result = await summaryProvider.complete({ messages: [{ role: 'user', content: summaryPrompt }], diff --git a/packages/gateway/src/routes/extensions/audit.ts b/packages/gateway/src/routes/extensions/audit.ts index 94d16065..4575f4fb 100644 --- a/packages/gateway/src/routes/extensions/audit.ts +++ b/packages/gateway/src/routes/extensions/audit.ts @@ -9,13 +9,7 @@ */ import { Hono } from 'hono'; -import { - createProvider, - getProviderConfig as coreGetProviderConfig, - getServiceRegistry, - Services, - type AIProvider, -} from '@ownpilot/core'; +import { getServiceRegistry, Services } from '@ownpilot/core'; import type { ExtensionService } from '../../services/extension-service.js'; import type { ExtensionManifest } from '../../services/extension-types.js'; import { @@ -33,28 +27,13 @@ import { getErrorMessage, parseJsonBody, } from '../helpers.js'; -import { resolveProviderAndModel, getApiKey } from '../settings.js'; -import { localProvidersRepo } from '../../db/repositories/index.js'; +import { resolveRuntimeProvider } from '../../services/model-execution.js'; import { getLog } from '../../services/log.js'; const log = getLog('ExtensionAudit'); export const auditRoutes = new Hono(); -/** Providers with native SDK support (others use OpenAI-compatible) */ -const NATIVE_PROVIDERS = new Set([ - 'openai', - 'anthropic', - 'google', - 'deepseek', - 'groq', - 'mistral', - 'xai', - 'together', - 'fireworks', - 'perplexity', -]); - const getExtService = () => getServiceRegistry().get(Services.Extension) as ExtensionService; // ============================================================================= @@ -139,45 +118,18 @@ async function runLlmAudit( modelOverride?: string ): Promise<{ result: SkillLlmAuditResult | null; error: string | null }> { try { - // 1. Resolve provider/model - const resolved = await resolveProviderAndModel( - providerOverride ?? 'default', - modelOverride ?? 'default' - ); - if (!resolved.provider || !resolved.model) { + const resolved = await resolveRuntimeProvider(providerOverride, modelOverride); + if (!resolved.providerId || !resolved.model || !resolved.instance) { return { result: null, error: 'No AI provider configured. Set up a provider in Settings for LLM analysis.', }; } - // 2. Get API key - const localProv = await localProvidersRepo.getProvider(resolved.provider); - const apiKey = localProv - ? localProv.apiKey || 'local-no-key' - : await getApiKey(resolved.provider); - if (!apiKey) { - return { - result: null, - error: `API key not configured for provider: ${resolved.provider}`, - }; - } - - // 3. Create provider instance - const providerConfig = coreGetProviderConfig(resolved.provider); - const providerType = NATIVE_PROVIDERS.has(resolved.provider) ? resolved.provider : 'openai'; - - const providerInstance = createProvider({ - provider: providerType as AIProvider, - apiKey, - baseUrl: providerConfig?.baseUrl, - headers: providerConfig?.headers, - }); - - // 4. Build prompt and call LLM + // 2. Build prompt and call LLM const prompt = buildLlmAuditPrompt(manifest, staticResult); - const result = await providerInstance.complete({ + const result = await resolved.instance.complete({ model: { model: resolved.model, maxTokens: 4096, temperature: 0.3 }, messages: [{ role: 'user' as const, content: prompt }], }); @@ -194,7 +146,7 @@ async function runLlmAudit( return { result: null, error: 'LLM returned empty response' }; } - // 5. Parse structured response + // 3. Parse structured response const auditResult = parseLlmAuditResponse(text); log.info('LLM audit completed', { diff --git a/packages/gateway/src/routes/extensions/eval.ts b/packages/gateway/src/routes/extensions/eval.ts index ec254a67..878a27e5 100644 --- a/packages/gateway/src/routes/extensions/eval.ts +++ b/packages/gateway/src/routes/extensions/eval.ts @@ -8,11 +8,8 @@ import { Hono } from 'hono'; import { - createProvider, - getProviderConfig as coreGetProviderConfig, getServiceRegistry, Services, - type AIProvider, } from '@ownpilot/core'; import type { ExtensionService } from '../../services/extension-service.js'; import { @@ -24,28 +21,13 @@ import { getErrorMessage, parseJsonBody, } from '../helpers.js'; -import { resolveProviderAndModel, getApiKey } from '../settings.js'; -import { localProvidersRepo } from '../../db/repositories/index.js'; +import { resolveRuntimeProvider } from '../../services/model-execution.js'; import { getLog } from '../../services/log.js'; const log = getLog('ExtensionEval'); export const evalRoutes = new Hono(); -/** Providers with native SDK support */ -const NATIVE_PROVIDERS = new Set([ - 'openai', - 'anthropic', - 'google', - 'deepseek', - 'groq', - 'mistral', - 'xai', - 'together', - 'fireworks', - 'perplexity', -]); - const getExtService = () => getServiceRegistry().get(Services.Extension) as ExtensionService; // --------------------------------------------------------------------------- @@ -53,33 +35,11 @@ const getExtService = () => getServiceRegistry().get(Services.Extension) as Exte // --------------------------------------------------------------------------- async function buildProvider(providerOverride?: string, modelOverride?: string) { - const resolved = await resolveProviderAndModel( - providerOverride ?? 'default', - modelOverride ?? 'default' - ); - if (!resolved.provider || !resolved.model) { + const resolved = await resolveRuntimeProvider(providerOverride, modelOverride); + if (!resolved.providerId || !resolved.model || !resolved.instance) { return { provider: null, model: null, error: 'No AI provider configured.' }; } - const localProv = await localProvidersRepo.getProvider(resolved.provider); - const apiKey = localProv - ? localProv.apiKey || 'local-no-key' - : await getApiKey(resolved.provider); - if (!apiKey) { - return { - provider: null, - model: null, - error: `API key not configured for: ${resolved.provider}`, - }; - } - const providerConfig = coreGetProviderConfig(resolved.provider); - const providerType = NATIVE_PROVIDERS.has(resolved.provider) ? resolved.provider : 'openai'; - const instance = createProvider({ - provider: providerType as AIProvider, - apiKey, - baseUrl: providerConfig?.baseUrl, - headers: providerConfig?.headers, - }); - return { provider: instance, model: resolved.model, error: null }; + return { provider: resolved.instance, model: resolved.model, error: null }; } // --------------------------------------------------------------------------- diff --git a/packages/gateway/src/routes/extensions/generation.ts b/packages/gateway/src/routes/extensions/generation.ts index 8110003d..e8c41ccd 100644 --- a/packages/gateway/src/routes/extensions/generation.ts +++ b/packages/gateway/src/routes/extensions/generation.ts @@ -5,34 +5,14 @@ */ import { Hono } from 'hono'; -import { - createProvider, - getProviderConfig as coreGetProviderConfig, - type AIProvider, -} from '@ownpilot/core'; import { validateManifest, type ExtensionManifest } from '../../services/extension-types.js'; import { serializeExtensionMarkdown } from '../../services/extension-markdown.js'; import { parseAgentSkillsMd } from '../../services/agentskills-parser.js'; import { apiResponse, apiError, ERROR_CODES, getErrorMessage, parseJsonBody } from '../helpers.js'; -import { resolveProviderAndModel, getApiKey } from '../settings.js'; -import { localProvidersRepo } from '../../db/repositories/index.js'; +import { resolveRuntimeProvider } from '../../services/model-execution.js'; export const generationRoutes = new Hono(); -/** Providers with native SDK support (others use OpenAI-compatible) */ -const NATIVE_PROVIDERS = new Set([ - 'openai', - 'anthropic', - 'google', - 'deepseek', - 'groq', - 'mistral', - 'xai', - 'together', - 'fireworks', - 'perplexity', -]); - // ============================================================================ // AI Generation Prompt // ============================================================================ @@ -429,9 +409,8 @@ generationRoutes.post('/generate', async (c) => { ); } - // 1. Resolve default provider/model - const { provider, model } = await resolveProviderAndModel('default', 'default'); - if (!provider || !model) { + const resolved = await resolveRuntimeProvider(); + if (!resolved.providerId || !resolved.model || !resolved.instance) { return apiError( c, { @@ -442,35 +421,9 @@ generationRoutes.post('/generate', async (c) => { ); } - // 2. Get API key - const localProv = await localProvidersRepo.getProvider(provider); - const apiKey = localProv ? localProv.apiKey || 'local-no-key' : await getApiKey(provider); - if (!apiKey) { - return apiError( - c, - { - code: ERROR_CODES.INVALID_REQUEST, - message: `API key not configured for provider: ${provider}`, - }, - 400 - ); - } - - // 3. Create provider - const providerConfig = coreGetProviderConfig(provider); - const providerType = NATIVE_PROVIDERS.has(provider) ? provider : 'openai'; - - const providerInstance = createProvider({ - provider: providerType as AIProvider, - apiKey, - baseUrl: providerConfig?.baseUrl, - headers: providerConfig?.headers, - }); - try { - // 4. Call AI - const result = await providerInstance.complete({ - model: { model, maxTokens: 4096, temperature: 0.7 }, + const result = await resolved.instance.complete({ + model: { model: resolved.model, maxTokens: 4096, temperature: 0.7 }, messages: [ { role: 'system' as const, content: EXTENSION_GENERATION_PROMPT }, { role: 'user' as const, content: body.description.trim() }, @@ -560,9 +513,8 @@ generationRoutes.post('/generate-skill', async (c) => { ); } - // 1. Resolve default provider/model - const { provider, model } = await resolveProviderAndModel('default', 'default'); - if (!provider || !model) { + const resolved = await resolveRuntimeProvider(); + if (!resolved.providerId || !resolved.model || !resolved.instance) { return apiError( c, { @@ -573,35 +525,9 @@ generationRoutes.post('/generate-skill', async (c) => { ); } - // 2. Get API key - const localProv = await localProvidersRepo.getProvider(provider); - const apiKey = localProv ? localProv.apiKey || 'local-no-key' : await getApiKey(provider); - if (!apiKey) { - return apiError( - c, - { - code: ERROR_CODES.INVALID_REQUEST, - message: `API key not configured for provider: ${provider}`, - }, - 400 - ); - } - - // 3. Create provider - const providerConfig = coreGetProviderConfig(provider); - const providerType = NATIVE_PROVIDERS.has(provider) ? provider : 'openai'; - - const providerInstance = createProvider({ - provider: providerType as AIProvider, - apiKey, - baseUrl: providerConfig?.baseUrl, - headers: providerConfig?.headers, - }); - try { - // 4. Call AI - const result = await providerInstance.complete({ - model: { model, maxTokens: 4096, temperature: 0.7 }, + const result = await resolved.instance.complete({ + model: { model: resolved.model, maxTokens: 4096, temperature: 0.7 }, messages: [ { role: 'system' as const, content: SKILL_GENERATION_PROMPT }, { role: 'user' as const, content: body.description.trim() }, diff --git a/packages/gateway/src/routes/models.test.ts b/packages/gateway/src/routes/models.test.ts index 8f42212a..39065032 100644 --- a/packages/gateway/src/routes/models.test.ts +++ b/packages/gateway/src/routes/models.test.ts @@ -66,6 +66,7 @@ const sampleProviderConfig2 = { const mockModelConfigsRepo = { getDisabledModelIds: vi.fn(async () => new Set()), + getDisabledBuiltinProviderIds: vi.fn(async () => new Set()), }; const mockLocalProvidersRepo = { @@ -115,6 +116,14 @@ vi.mock('../db/repositories/index.js', () => ({ localProvidersRepo: mockLocalProvidersRepo, })); +vi.mock('../services/model-execution.js', () => ({ + getCliRuntimeModels: vi.fn(() => []), + getRuntimeDefaultModel: vi.fn(() => null), + isCliRuntimeProvider: vi.fn(() => false), + isCliRuntimeProviderAvailable: vi.fn(() => false), + listCliRuntimeProviderIds: vi.fn(() => []), +})); + // Import after mocks const { modelsRoutes } = await import('./models.js'); @@ -140,6 +149,7 @@ describe('Models Routes', () => { beforeEach(() => { vi.clearAllMocks(); mockModelConfigsRepo.getDisabledModelIds.mockResolvedValue(new Set()); + mockModelConfigsRepo.getDisabledBuiltinProviderIds.mockResolvedValue(new Set()); mockLocalProvidersRepo.listProviders.mockResolvedValue([]); mockLocalProvidersRepo.listModels.mockResolvedValue([]); app = createApp(); diff --git a/packages/gateway/src/routes/models.ts b/packages/gateway/src/routes/models.ts index 372f54cb..10c86bc7 100644 --- a/packages/gateway/src/routes/models.ts +++ b/packages/gateway/src/routes/models.ts @@ -18,6 +18,13 @@ import { } from '@ownpilot/core'; import { modelConfigsRepo } from '../db/repositories/model-configs.js'; import { localProvidersRepo } from '../db/repositories/index.js'; +import { + getCliRuntimeModels, + getRuntimeDefaultModel, + isCliRuntimeProvider, + isCliRuntimeProviderAvailable, + listCliRuntimeProviderIds, +} from '../services/model-execution.js'; import { getUserId, apiResponse, @@ -79,15 +86,34 @@ app.get('/', async (c) => { const allModels: ModelInfo[] = []; const configuredProviders: string[] = []; - const availableProviders = getAvailableProviders(); + const availableProviders = [...getAvailableProviders(), ...listCliRuntimeProviderIds()]; // Get disabled models for filtering const disabledModels = enabledOnly ? await modelConfigsRepo.getDisabledModelIds(userId) : new Set(); + const disabledProviders = await modelConfigsRepo.getDisabledBuiltinProviderIds(userId); // Check all available providers for (const provider of availableProviders) { + if (isCliRuntimeProvider(provider)) { + if (isCliRuntimeProviderAvailable(provider) && !disabledProviders.has(provider)) { + configuredProviders.push(provider); + const cliModels = (await getCliRuntimeModels(provider)).map((m) => ({ + id: m.id, + name: m.name, + provider, + contextWindow: 0, + maxOutputTokens: undefined, + inputPrice: 0, + outputPrice: 0, + capabilities: ['chat'], + recommended: m.id === getRuntimeDefaultModel(provider), + })); + allModels.push(...cliModels); + } + continue; + } if (await hasApiKey(provider)) { configuredProviders.push(provider); let models = convertToModelInfo(provider); @@ -102,11 +128,11 @@ app.get('/', async (c) => { } // Include models from local providers (LM Studio, Ollama, etc.) - const localProviders = await localProvidersRepo.listProviders(); + const localProviders = await localProvidersRepo.listProviders(userId); for (const lp of localProviders) { if (!lp.isEnabled) continue; configuredProviders.push(lp.id); - const localModels = await localProvidersRepo.listModels(undefined, lp.id); + const localModels = await localProvidersRepo.listModels(userId, lp.id); for (const lm of localModels) { if (!lm.isEnabled) continue; allModels.push({ @@ -211,6 +237,15 @@ app.post('/sync', async (c) => { app.get('/:provider', async (c) => { const provider = c.req.param('provider'); + if (isCliRuntimeProvider(provider)) { + return apiResponse(c, { + provider, + providerName: provider, + models: await getCliRuntimeModels(provider), + isConfigured: isCliRuntimeProviderAvailable(provider), + }); + } + const config = getProviderConfig(provider); if (!config) { return apiError( diff --git a/packages/gateway/src/routes/providers.test.ts b/packages/gateway/src/routes/providers.test.ts index 42d1e108..0540c083 100644 --- a/packages/gateway/src/routes/providers.test.ts +++ b/packages/gateway/src/routes/providers.test.ts @@ -40,6 +40,13 @@ vi.mock('./settings.js', () => ({ getApiKeySource: vi.fn(async () => null), })); +vi.mock('../services/model-execution.js', () => ({ + getCliRuntimeModels: vi.fn(() => []), + getCliRuntimeProviderMetadata: vi.fn(() => null), + isCliRuntimeProvider: vi.fn(() => false), + listCliRuntimeProviderIds: vi.fn(() => []), +})); + vi.mock('@ownpilot/core', async (importOriginal) => { const original = await importOriginal>(); return { diff --git a/packages/gateway/src/routes/providers.ts b/packages/gateway/src/routes/providers.ts index 2f13faef..45008723 100644 --- a/packages/gateway/src/routes/providers.ts +++ b/packages/gateway/src/routes/providers.ts @@ -19,6 +19,12 @@ import { import { hasApiKey, getApiKeySource } from './settings.js'; import { modelConfigsRepo } from '../db/repositories/model-configs.js'; import { localProvidersRepo } from '../db/repositories/index.js'; +import { + getCliRuntimeModels, + getCliRuntimeProviderMetadata, + isCliRuntimeProvider, + listCliRuntimeProviderIds, +} from '../services/model-execution.js'; const app = new Hono(); @@ -121,6 +127,7 @@ const DEFAULT_UI_METADATA: { color: string; apiKeyPlaceholder?: string } = { col // Provider categories for UI organization const PROVIDER_CATEGORIES: Record = { Popular: ['openai', 'anthropic', 'google', 'deepseek', 'groq', 'mistral', 'xai'], + 'CLI Providers': ['claude-cli', 'codex-cli', 'gemini-cli'], 'Cloud Platforms': [ 'azure', 'amazon-bedrock', @@ -212,7 +219,35 @@ const PROVIDER_CATEGORIES: Record = { * Get all available provider IDs (from core PROVIDER_IDS) */ function getProviderIds(): string[] { - return [...PROVIDER_IDS]; + return [...PROVIDER_IDS, ...listCliRuntimeProviderIds()]; +} + +interface ProviderListItem { + id: string; + name: string; + type: string; + baseUrl?: string; + apiKeyEnv: string; + docsUrl?: string; + features: { + streaming: boolean; + toolUse: boolean; + vision: boolean; + jsonMode: boolean; + systemMessage: boolean; + }; + modelCount: number; + isConfigured: boolean; + isEnabled: boolean; + hasOverride: boolean; + configSource: string | null; + color: string; + apiKeyPlaceholder?: string; + transport: 'http' | 'cli' | 'local'; + authMethod: 'api-key' | 'login' | 'both' | 'none'; + family: string; + fallbackProviderId?: string; + version?: string; } /** @@ -227,7 +262,41 @@ app.get('/', async (c) => { const overrideMap = new Map(userOverrides.map((o) => [o.providerId, o])); // Build provider list with async API key checks - const providerPromises = providerIds.map(async (id) => { + const providerPromises: Array> = providerIds.map(async (id) => { + if (isCliRuntimeProvider(id)) { + const meta = getCliRuntimeProviderMetadata(id); + if (!meta) return null; + const override = overrideMap.get(id); + const isEnabled = override?.isEnabled !== false && meta.isAvailable; + return { + id: meta.id, + name: meta.displayName, + type: 'cli', + baseUrl: undefined, + apiKeyEnv: meta.envVar ?? '', + docsUrl: meta.docsUrl, + features: { + streaming: true, + toolUse: false, + vision: false, + jsonMode: false, + systemMessage: true, + }, + modelCount: (await getCliRuntimeModels(id)).length, + isConfigured: meta.isConfigured && isEnabled, + isEnabled, + hasOverride: !!override, + configSource: meta.isAvailable ? 'cli' : null, + color: '#f59e0b', + apiKeyPlaceholder: undefined, + transport: meta.transport, + authMethod: meta.authMethod, + family: meta.family, + fallbackProviderId: meta.fallbackProviderId, + version: meta.version, + }; + } + const config = loadProviderConfig(id); if (!config) return null; @@ -261,11 +330,17 @@ app.get('/', async (c) => { // UI metadata color: uiMeta.color, apiKeyPlaceholder: uiMeta.apiKeyPlaceholder, + transport: 'http' as const, + authMethod: 'api-key' as const, + family: config.id, + fallbackProviderId: undefined, }; }); const providersWithNulls = await Promise.all(providerPromises); - const providers = providersWithNulls.filter((p): p is NonNullable => p !== null); + const providers: ProviderListItem[] = providersWithNulls.filter( + (p): p is ProviderListItem => p !== null + ); // Include local providers (LM Studio, Ollama, etc.) const localProviderColors: Record = { @@ -275,10 +350,10 @@ app.get('/', async (c) => { vllm: '#f97316', custom: '#666666', }; - const dbLocalProviders = await localProvidersRepo.listProviders(); + const dbLocalProviders = await localProvidersRepo.listProviders(userId); for (const lp of dbLocalProviders) { if (!lp.isEnabled) continue; - const localModels = await localProvidersRepo.listModels(undefined, lp.id); + const localModels = await localProvidersRepo.listModels(userId, lp.id); providers.push({ id: lp.id, name: lp.name, @@ -300,6 +375,11 @@ app.get('/', async (c) => { configSource: 'database' as const, color: localProviderColors[lp.providerType] ?? '#10b981', apiKeyPlaceholder: undefined, + transport: 'local' as const, + authMethod: 'none' as const, + family: lp.providerType, + fallbackProviderId: undefined, + version: undefined, }); } @@ -331,6 +411,36 @@ app.get('/categories', (c) => { app.get('/:id', async (c) => { const id = c.req.param('id'); const userId = getUserId(c); + if (isCliRuntimeProvider(id)) { + const meta = getCliRuntimeProviderMetadata(id); + if (!meta) { + return apiError( + c, + { code: ERROR_CODES.PROVIDER_NOT_FOUND, message: `Provider '${id}' not found` }, + 404 + ); + } + + return apiResponse(c, { + id: meta.id, + name: meta.displayName, + type: 'cli', + apiKeyEnv: meta.envVar ?? '', + docsUrl: meta.docsUrl, + isConfigured: meta.isConfigured, + isEnabled: meta.isAvailable, + hasOverride: false, + userOverride: null, + color: '#f59e0b', + apiKeyPlaceholder: undefined, + transport: meta.transport, + authMethod: meta.authMethod, + family: meta.family, + fallbackProviderId: meta.fallbackProviderId, + version: meta.version, + models: await getCliRuntimeModels(id), + }); + } const config = loadProviderConfig(id); if (!config) { @@ -370,14 +480,34 @@ app.get('/:id', async (c) => { // UI metadata color: uiMeta.color, apiKeyPlaceholder: uiMeta.apiKeyPlaceholder, + transport: 'http' as const, + authMethod: 'api-key' as const, + family: config.id, + fallbackProviderId: undefined, }); }); /** * GET /providers/:id/models - Get models for a provider */ -app.get('/:id/models', (c) => { +app.get('/:id/models', async (c) => { const id = c.req.param('id'); + if (isCliRuntimeProvider(id)) { + const meta = getCliRuntimeProviderMetadata(id); + if (!meta) { + return apiError( + c, + { code: ERROR_CODES.PROVIDER_NOT_FOUND, message: `Provider '${id}' not found` }, + 404 + ); + } + return apiResponse(c, { + provider: id, + providerName: meta.displayName, + models: await getCliRuntimeModels(id), + isConfigured: meta.isConfigured, + }); + } const config = loadProviderConfig(id); if (!config) { @@ -396,12 +526,35 @@ app.get('/:id/models', (c) => { }); }); + /** * GET /providers/:id/config - Get user config overrides for a provider */ app.get('/:id/config', async (c) => { const id = c.req.param('id'); const userId = getUserId(c); + if (isCliRuntimeProvider(id)) { + const userConfig = await modelConfigsRepo.getUserProviderConfig(userId, id); + return apiResponse(c, { + providerId: id, + baseConfig: null, + userOverride: userConfig + ? { + baseUrl: userConfig.baseUrl, + providerType: userConfig.providerType, + isEnabled: userConfig.isEnabled, + apiKeyEnv: userConfig.apiKeyEnv, + notes: userConfig.notes, + } + : null, + effectiveConfig: { + type: 'cli', + baseUrl: undefined, + apiKeyEnv: getCliRuntimeProviderMetadata(id)?.envVar ?? '', + isEnabled: userConfig?.isEnabled !== false && !!getCliRuntimeProviderMetadata(id)?.isAvailable, + }, + }); + } const config = loadProviderConfig(id); if (!config) { @@ -449,6 +602,16 @@ app.get('/:id/config', async (c) => { app.put('/:id/config', async (c) => { const id = c.req.param('id'); const userId = getUserId(c); + if (isCliRuntimeProvider(id)) { + return apiError( + c, + { + code: ERROR_CODES.INVALID_REQUEST, + message: 'CLI-backed providers do not support user config overrides', + }, + 400 + ); + } const config = loadProviderConfig(id); if (!config) { @@ -508,6 +671,14 @@ app.put('/:id/config', async (c) => { app.delete('/:id/config', async (c) => { const id = c.req.param('id'); const userId = getUserId(c); + if (isCliRuntimeProvider(id)) { + const deleted = await modelConfigsRepo.deleteUserProviderConfig(userId, id); + return apiResponse(c, { + providerId: id, + deleted, + restored: true, + }); + } const deleted = await modelConfigsRepo.deleteUserProviderConfig(userId, id); @@ -523,6 +694,35 @@ app.delete('/:id/config', async (c) => { app.patch('/:id/toggle', async (c) => { const id = c.req.param('id'); const userId = getUserId(c); + if (isCliRuntimeProvider(id)) { + try { + const body = await parseJsonBody(c); + const { toggleEnabledSchema } = await import('../middleware/validation.js'); + const parsed = toggleEnabledSchema.safeParse(body); + + if (!parsed.success) { + return zodValidationError(c, parsed.error.issues); + } + + const { enabled } = parsed.data; + await modelConfigsRepo.toggleUserProviderConfig(userId, id, enabled); + const userConfig = await modelConfigsRepo.getUserProviderConfig(userId, id); + + return apiResponse(c, { + providerId: id, + enabled: userConfig?.isEnabled !== false, + }); + } catch (error) { + return apiError( + c, + { + code: ERROR_CODES.TOGGLE_FAILED, + message: getErrorMessage(error, 'Failed to toggle CLI provider'), + }, + 500 + ); + } + } const config = loadProviderConfig(id); if (!config) { diff --git a/packages/gateway/src/routes/settings.test.ts b/packages/gateway/src/routes/settings.test.ts index 3187cb48..3d3aa17b 100644 --- a/packages/gateway/src/routes/settings.test.ts +++ b/packages/gateway/src/routes/settings.test.ts @@ -28,15 +28,21 @@ const mockLocalProvidersRepo = { getDefault: vi.fn(), }; +const mockModelConfigsRepo = { + getDisabledBuiltinProviderIds: vi.fn(async () => new Set()), +}; + vi.mock('../db/repositories/index.js', () => ({ settingsRepo: null as unknown, // replaced in beforeEach localProvidersRepo: null as unknown, + modelConfigsRepo: null as unknown, })); // Patch mock objects onto the module import * as repoModule from '../db/repositories/index.js'; (repoModule as Record).settingsRepo = mockSettingsRepo; (repoModule as Record).localProvidersRepo = mockLocalProvidersRepo; +(repoModule as Record).modelConfigsRepo = mockModelConfigsRepo; vi.mock('@ownpilot/core', async (importOriginal) => { const original = await importOriginal>(); @@ -84,6 +90,14 @@ vi.mock('../paths/migration.js', () => ({ })), })); +vi.mock('../services/model-execution.js', () => ({ + getRuntimeDefaultModel: vi.fn(() => null), + hasAnyCliRuntimeProviderAvailable: vi.fn(() => false), + isCliRuntimeProvider: vi.fn(() => false), + isCliRuntimeProviderAvailable: vi.fn(() => false), + listCliRuntimeProviderIds: vi.fn(() => []), +})); + // Import after mocks const { settingsRoutes } = await import('./settings.js'); @@ -109,6 +123,25 @@ describe('Settings Routes', () => { beforeEach(() => { vi.clearAllMocks(); + mockSettingsRepo.get.mockReset(); + mockSettingsRepo.set.mockReset(); + mockSettingsRepo.has.mockReset(); + mockSettingsRepo.delete.mockReset(); + mockSettingsRepo.getByPrefix.mockReset(); + mockLocalProvidersRepo.listProviders.mockReset(); + mockLocalProvidersRepo.getProvider.mockReset(); + mockLocalProvidersRepo.getDefault.mockReset(); + mockModelConfigsRepo.getDisabledBuiltinProviderIds.mockReset(); + + mockSettingsRepo.getByPrefix.mockResolvedValue([]); + mockSettingsRepo.get.mockResolvedValue(null); + mockSettingsRepo.has.mockResolvedValue(false); + mockSettingsRepo.set.mockResolvedValue(undefined); + mockSettingsRepo.delete.mockResolvedValue(undefined); + mockLocalProvidersRepo.listProviders.mockResolvedValue([]); + mockLocalProvidersRepo.getProvider.mockResolvedValue(null); + mockLocalProvidersRepo.getDefault.mockResolvedValue(null); + mockModelConfigsRepo.getDisabledBuiltinProviderIds.mockResolvedValue(new Set()); app = createApp(); }); diff --git a/packages/gateway/src/routes/settings.ts b/packages/gateway/src/routes/settings.ts index 959d0d76..14594df8 100644 --- a/packages/gateway/src/routes/settings.ts +++ b/packages/gateway/src/routes/settings.ts @@ -7,7 +7,7 @@ import { Hono } from 'hono'; import { apiResponse, apiError, ERROR_CODES, getErrorMessage } from './helpers.js'; -import { settingsRepo, localProvidersRepo } from '../db/repositories/index.js'; +import { settingsRepo, localProvidersRepo, modelConfigsRepo } from '../db/repositories/index.js'; import { getAvailableProviders, getDefaultModelForProvider, @@ -19,6 +19,12 @@ import { } from '@ownpilot/core'; import { getDataDirectoryInfo } from '../paths/index.js'; import { getMigrationStatus } from '../paths/migration.js'; +import { + isCliRuntimeProvider, + isCliRuntimeProviderAvailable, + listCliRuntimeProviderIds, + getRuntimeDefaultModel, +} from '../services/model-execution.js'; export const settingsRoutes = new Hono(); @@ -37,6 +43,11 @@ settingsRoutes.get('/', async (c) => { // Get all API key settings from database only const apiKeySettings = await settingsRepo.getByPrefix(API_KEY_PREFIX); const configuredProviders = apiKeySettings.map((s) => s.key.replace(API_KEY_PREFIX, '')); + const userId = 'default'; + const disabledProviders = await modelConfigsRepo.getDisabledBuiltinProviderIds(userId); + const cliProviders = listCliRuntimeProviderIds().filter( + (id) => isCliRuntimeProviderAvailable(id) && !disabledProviders.has(id) + ); // Include enabled local providers as configured (they don't need API keys) const localProviders = await localProvidersRepo.listProviders(); @@ -46,7 +57,7 @@ settingsRoutes.get('/', async (c) => { const localProviderIds = enabledLocalProviders.map((lp) => lp.id); // Merge: remote configured + local enabled - const allConfiguredProviders = [...configuredProviders, ...localProviderIds]; + const allConfiguredProviders = [...configuredProviders, ...localProviderIds, ...cliProviders]; // Get default provider/model settings const defaultProvider = await settingsRepo.get(DEFAULT_PROVIDER_KEY); @@ -61,7 +72,7 @@ settingsRoutes.get('/', async (c) => { demoMode: allConfiguredProviders.length === 0, defaultProvider: defaultProvider ?? null, defaultModel: defaultModel ?? null, - availableProviders, + availableProviders: [...availableProviders, ...listCliRuntimeProviderIds()], }); }); @@ -204,6 +215,9 @@ settingsRoutes.delete('/api-keys/:provider', async (c) => { * Check if a provider has an API key configured (database only) */ export async function hasApiKey(provider: string): Promise { + if (isCliRuntimeProvider(provider)) { + return isCliRuntimeProviderAvailable(provider); + } const key = `${API_KEY_PREFIX}${provider}`; return await settingsRepo.has(key); } @@ -212,6 +226,9 @@ export async function hasApiKey(provider: string): Promise { * Get API key for a provider (database only) */ export async function getApiKey(provider: string): Promise { + if (isCliRuntimeProvider(provider)) { + return undefined; + } const key = `${API_KEY_PREFIX}${provider}`; return (await settingsRepo.get(key)) ?? undefined; } @@ -222,7 +239,13 @@ export async function getApiKey(provider: string): Promise { */ export async function getConfiguredProviderIds(): Promise> { const apiKeySettings = await settingsRepo.getByPrefix(API_KEY_PREFIX); - return new Set(apiKeySettings.map((s) => s.key.replace(API_KEY_PREFIX, ''))); + const disabledProviders = await modelConfigsRepo.getDisabledBuiltinProviderIds('default'); + return new Set([ + ...apiKeySettings.map((s) => s.key.replace(API_KEY_PREFIX, '')), + ...listCliRuntimeProviderIds().filter( + (id) => isCliRuntimeProviderAvailable(id) && !disabledProviders.has(id) + ), + ]); } /** @@ -247,9 +270,17 @@ export async function loadApiKeysToEnvironment(): Promise { * Returns null if no provider is configured */ export async function getDefaultProvider(): Promise { + const disabledProviders = await modelConfigsRepo.getDisabledBuiltinProviderIds('default'); // Check database setting const savedProvider = await settingsRepo.get(DEFAULT_PROVIDER_KEY); if (savedProvider) { + if ( + isCliRuntimeProvider(savedProvider) && + isCliRuntimeProviderAvailable(savedProvider) && + !disabledProviders.has(savedProvider) + ) { + return savedProvider; + } // Check if it's a local provider const localProv = await localProvidersRepo.getProvider(savedProvider); if (localProv?.isEnabled) return savedProvider; @@ -268,6 +299,14 @@ export async function getDefaultProvider(): Promise { return firstSetting.key.replace(API_KEY_PREFIX, ''); } + // Fall back to first available CLI provider + const cliProvider = listCliRuntimeProviderIds().find( + (id) => isCliRuntimeProviderAvailable(id) && !disabledProviders.has(id) + ); + if (cliProvider) { + return cliProvider; + } + // No providers configured return null; } @@ -296,6 +335,10 @@ export async function getDefaultModel(provider?: string): Promise return null; } + if (isCliRuntimeProvider(actualProvider)) { + return getRuntimeDefaultModel(actualProvider); + } + const defaultModel = getDefaultModelForProvider(actualProvider); return defaultModel?.id ?? null; } @@ -328,6 +371,12 @@ export async function isDemoModeFromSettings(): Promise { const apiKeySettings = await settingsRepo.getByPrefix(API_KEY_PREFIX); if (apiKeySettings.length > 0) return false; + const disabledProviders = await modelConfigsRepo.getDisabledBuiltinProviderIds('default'); + const hasEnabledCli = listCliRuntimeProviderIds().some( + (id) => isCliRuntimeProviderAvailable(id) && !disabledProviders.has(id) + ); + if (hasEnabledCli) return false; + // Check local providers (Ollama, LM Studio, etc.) const localProviders = await localProvidersRepo.listProviders(); if (localProviders.some((p) => p.isEnabled)) return false; @@ -339,7 +388,10 @@ export async function isDemoModeFromSettings(): Promise { * Get the source of an API key (database only now) * Returns 'database' if key exists, null otherwise */ -export async function getApiKeySource(provider: string): Promise<'database' | null> { +export async function getApiKeySource(provider: string): Promise<'database' | 'cli' | null> { + if (isCliRuntimeProvider(provider)) { + return isCliRuntimeProviderAvailable(provider) ? 'cli' : null; + } const key = `${API_KEY_PREFIX}${provider}`; return (await settingsRepo.has(key)) ? 'database' : null; } diff --git a/packages/gateway/src/routes/workflow-copilot.ts b/packages/gateway/src/routes/workflow-copilot.ts index 262781ec..6855cc8a 100644 --- a/packages/gateway/src/routes/workflow-copilot.ts +++ b/packages/gateway/src/routes/workflow-copilot.ts @@ -2,19 +2,18 @@ * Workflow Copilot — SSE streaming endpoint. * * Lightweight AI chat for generating/editing workflow JSON definitions. - * Uses createProvider().stream() directly — no agent infrastructure needed. + * Uses the shared runtime provider resolver — no agent infrastructure needed. */ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { apiError, getErrorMessage, parseJsonBody } from './helpers.js'; import { ERROR_CODES } from './error-codes.js'; -import { getProviderApiKey, loadProviderConfig, NATIVE_PROVIDERS } from './agent-cache.js'; -import { resolveProviderAndModel } from './settings.js'; import { validateBody, workflowCopilotSchema } from '../middleware/validation.js'; -import { createProvider, type ProviderConfig, type Message } from '@ownpilot/core'; +import type { Message } from '@ownpilot/core'; import { buildCopilotSystemPrompt } from './workflow-copilot-prompt.js'; import { getLog } from '../services/log.js'; +import { resolveRuntimeProvider } from '../services/model-execution.js'; const log = getLog('WorkflowCopilot'); @@ -50,12 +49,8 @@ workflowCopilotRoute.post('/', async (c) => { } // Resolve provider and model (fall back to user defaults) - const { provider: resolvedProvider, model: resolvedModel } = await resolveProviderAndModel( - body.provider ?? 'default', - body.model ?? 'default' - ); - - if (!resolvedProvider) { + const resolved = await resolveRuntimeProvider(body.provider, body.model); + if (!resolved.providerId || !resolved.model || !resolved.instance) { return apiError( c, { @@ -66,31 +61,8 @@ workflowCopilotRoute.post('/', async (c) => { ); } - const apiKey = await getProviderApiKey(resolvedProvider); - if (!apiKey) { - return apiError( - c, - { - code: ERROR_CODES.PROVIDER_NOT_FOUND, - message: `API key not configured for provider: ${resolvedProvider}`, - }, - 400 - ); - } - - // Resolve base URL for custom/local providers - let baseUrl: string | undefined; - const config = loadProviderConfig(resolvedProvider); - if (config?.baseUrl) baseUrl = config.baseUrl; - - const providerType = NATIVE_PROVIDERS.has(resolvedProvider) ? resolvedProvider : 'openai'; - - const provider = createProvider({ - provider: providerType as ProviderConfig['provider'], - apiKey, - baseUrl, - headers: config?.headers, - }); + const provider = resolved.instance; + const model = resolved.model; // Build system prompt const systemPrompt = buildCopilotSystemPrompt(body.currentWorkflow, body.availableTools); @@ -108,7 +80,7 @@ workflowCopilotRoute.post('/', async (c) => { const generator = provider.stream({ messages, model: { - model: resolvedModel ?? 'gpt-4o', + model, maxTokens: 8192, temperature: 0.7, }, diff --git a/packages/gateway/src/services/binary-utils.test.ts b/packages/gateway/src/services/binary-utils.test.ts index ce19abc4..a07a6106 100644 --- a/packages/gateway/src/services/binary-utils.test.ts +++ b/packages/gateway/src/services/binary-utils.test.ts @@ -39,6 +39,7 @@ import { isBinaryInstalled, getBinaryVersion, validateCwd, + createLoginOnlyCliEnv, createSanitizedEnv, spawnCliProcess, MAX_OUTPUT_SIZE, @@ -275,6 +276,32 @@ describe('createSanitizedEnv', () => { }); }); +describe('createLoginOnlyCliEnv', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + CODEX_API_KEY: 'parent-codex-key', + GEMINI_API_KEY: 'parent-gemini-key', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('removes inherited CODEX_API_KEY for codex login mode', () => { + const env = createLoginOnlyCliEnv('codex'); + expect(env.CODEX_API_KEY).toBeUndefined(); + }); + + it('removes inherited GEMINI_API_KEY for gemini login mode', () => { + const env = createLoginOnlyCliEnv('gemini-cli'); + expect(env.GEMINI_API_KEY).toBeUndefined(); + }); +}); + // ============================================================================ // spawnCliProcess // ============================================================================ diff --git a/packages/gateway/src/services/binary-utils.ts b/packages/gateway/src/services/binary-utils.ts index 11eff8c4..d31ab15a 100644 --- a/packages/gateway/src/services/binary-utils.ts +++ b/packages/gateway/src/services/binary-utils.ts @@ -22,7 +22,9 @@ export const MAX_OUTPUT_SIZE = 1_048_576; // 1 MB /** Built-in provider env var names (needed by createSanitizedEnv) */ const API_KEY_ENV_VARS: Record = { 'claude-code': 'ANTHROPIC_API_KEY', + 'claude-cli': 'ANTHROPIC_API_KEY', codex: 'CODEX_API_KEY', + 'codex-cli': 'CODEX_API_KEY', 'gemini-cli': 'GEMINI_API_KEY', }; @@ -124,6 +126,25 @@ export function createSanitizedEnv( return env; } +/** + * Create an environment for CLI providers that must use local login/session auth. + * Any matching API key env var is removed, even if it exists in the parent process. + */ +export function createLoginOnlyCliEnv( + provider: string, + apiKeyEnvVar?: string +): Record { + const env = createSanitizedEnv(provider); + const envVarName = + apiKeyEnvVar ?? (isBuiltinProvider(provider) ? API_KEY_ENV_VARS[provider] : undefined); + + if (envVarName) { + delete env[envVarName]; + } + + return env; +} + // ============================================================================= // PROCESS SPAWNING // ============================================================================= diff --git a/packages/gateway/src/services/coding-agent-service.test.ts b/packages/gateway/src/services/coding-agent-service.test.ts index eee9768e..aaf27f41 100644 --- a/packages/gateway/src/services/coding-agent-service.test.ts +++ b/packages/gateway/src/services/coding-agent-service.test.ts @@ -316,17 +316,18 @@ describe('CodingAgentService', () => { for (const status of statuses) { expect(status.displayName).toBeTruthy(); - expect(status.configured).toBe(false); expect(status.ptyAvailable).toBe(false); } }); - it('detects configured providers', async () => { + it('marks CLI providers as configured when installed even without an API key', async () => { mockGetApiKey.mockImplementation((name: string) => { if (name === 'coding-codex') return 'sk-test'; return undefined; }); - mockExecFileSync.mockImplementation(() => { + mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => { + if ((cmd === 'which' || cmd === 'where') && args?.[0] === 'codex') return '/usr/bin/codex'; + if (cmd === 'codex' && args?.[0] === '--version') return 'codex v1.0.0\n'; throw new Error('not found'); }); mockTryImport.mockRejectedValue(new Error('not installed')); @@ -336,9 +337,6 @@ describe('CodingAgentService', () => { const codexStatus = statuses.find((s) => s.provider === 'codex'); expect(codexStatus?.configured).toBe(true); - - const claudeStatus = statuses.find((s) => s.provider === 'claude-code'); - expect(claudeStatus?.configured).toBe(false); }); }); diff --git a/packages/gateway/src/services/coding-agent-service.ts b/packages/gateway/src/services/coding-agent-service.ts index 26e566d0..766f4e44 100644 --- a/packages/gateway/src/services/coding-agent-service.ts +++ b/packages/gateway/src/services/coding-agent-service.ts @@ -34,6 +34,7 @@ import { getBinaryVersion, validateCwd, createSanitizedEnv, + createLoginOnlyCliEnv, spawnCliProcess, } from './binary-utils.js'; import { getLog } from './log.js'; @@ -80,12 +81,13 @@ const CLI_BINARIES: Record = { /** * Auth method for each built-in provider: * - 'api-key': SDK mode requires an API key (Claude Code SDK) - * - 'both': CLI supports login-based auth OR API key (Codex, Gemini, Claude CLI) + * - 'login': CLI uses local login/session auth only + * - 'both': CLI supports either login/session auth or API key */ const AUTH_METHODS: Record = { - 'claude-code': 'both', // SDK needs key, but CLI supports OAuth login - codex: 'both', // ChatGPT login or CODEX_API_KEY - 'gemini-cli': 'both', // Google account login or GEMINI_API_KEY + 'claude-code': 'both', // SDK needs key, PTY/CLI mode can use login + codex: 'both', + 'gemini-cli': 'both', }; // ============================================================================= @@ -219,7 +221,7 @@ async function runCodex(task: CodingAgentTask, apiKey?: string): Promise; + fallbackProviderId: 'anthropic' | 'openai' | 'google'; +} + +const CLI_PROVIDER_DEFS: Record = { + 'claude-cli': { + id: 'claude-cli', + displayName: 'Claude CLI', + family: 'anthropic', + binary: 'claude', + authMethod: 'both', + envVar: 'ANTHROPIC_API_KEY', + docsUrl: 'https://console.anthropic.com', + installCommand: 'npm i -g @anthropic-ai/claude-code', + defaultModel: 'default', + presetModels: [ + { id: 'default', name: 'Default' }, + { id: 'sonnet', name: 'Sonnet' }, + { id: 'opus', name: 'Opus' }, + { id: 'haiku', name: 'Haiku' }, + ], + fallbackProviderId: 'anthropic', + }, + 'codex-cli': { + id: 'codex-cli', + displayName: 'Codex CLI', + family: 'openai', + binary: 'codex', + authMethod: 'both', + envVar: 'CODEX_API_KEY', + docsUrl: 'https://platform.openai.com', + installCommand: 'npm i -g @openai/codex', + defaultModel: 'gpt-5.1-codex-mini', + presetModels: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' }, + { id: 'gpt-5.4', name: 'GPT-5.4' }, + { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex' }, + { id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max' }, + { id: 'gpt-5.2', name: 'GPT-5.2' }, + { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini' }, + ], + fallbackProviderId: 'openai', + }, + 'gemini-cli': { + id: 'gemini-cli', + displayName: 'Gemini CLI', + family: 'google', + binary: 'gemini', + authMethod: 'both', + envVar: 'GEMINI_API_KEY', + docsUrl: 'https://aistudio.google.com', + installCommand: 'npm i -g @google/gemini-cli', + defaultModel: 'gemini-2.5-flash-lite', + presetModels: [ + { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro Preview' }, + { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview' }, + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, + { id: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite' }, + ], + fallbackProviderId: 'google', + }, +}; + +export function isCliRuntimeProvider(providerId: string): providerId is keyof typeof CLI_PROVIDER_DEFS { + return providerId in CLI_PROVIDER_DEFS; +} + +export function listCliRuntimeProviderIds(): string[] { + return Object.keys(CLI_PROVIDER_DEFS); +} + +export function getCliRuntimeProviderDefinition( + providerId: string +): CliRuntimeProviderDefinition | null { + return isCliRuntimeProvider(providerId) ? CLI_PROVIDER_DEFS[providerId] : null; +} + +export function getRuntimeTransport(providerId: string): RuntimeTransport { + if (isCliRuntimeProvider(providerId)) return 'cli'; + return 'http'; +} + +export function getRuntimeFallbackProviderId(providerId: string): string | undefined { + if (isCliRuntimeProvider(providerId)) { + return getCliRuntimeProviderDefinition(providerId)?.fallbackProviderId; + } + return undefined; +} + +export function getRuntimeDefaultModel(providerId: string): string | null { + if (isCliRuntimeProvider(providerId)) { + return CLI_PROVIDER_DEFS[providerId].defaultModel; + } + return null; +} + +function getCliRuntimePresetModels(providerId: string): Array<{ id: string; name: string }> { + const def = getCliRuntimeProviderDefinition(providerId); + if (!def) return []; + return def.presetModels; +} + +export async function getCliRuntimeModels( + providerId: string +): Promise> { + const def = getCliRuntimeProviderDefinition(providerId); + if (!def) return []; + return getCliRuntimePresetModels(def.id); +} + +export function isCliRuntimeProviderAvailable(providerId: string): boolean { + const def = getCliRuntimeProviderDefinition(providerId); + return def ? isBinaryInstalled(def.binary) : false; +} + +export function hasAnyCliRuntimeProviderAvailable(): boolean { + return listCliRuntimeProviderIds().some((id) => isCliRuntimeProviderAvailable(id)); +} + +export function getCliRuntimeProviderMetadata(providerId: string): RuntimeProviderMetadata | null { + const def = getCliRuntimeProviderDefinition(providerId); + if (!def) return null; + + const isAvailable = isBinaryInstalled(def.binary); + return { + id: def.id, + displayName: def.displayName, + transport: 'cli', + family: def.family, + authMethod: def.authMethod, + isAvailable, + isConfigured: isAvailable, + fallbackProviderId: def.fallbackProviderId, + docsUrl: def.docsUrl, + installCommand: def.installCommand, + envVar: def.envVar, + version: isAvailable ? getBinaryVersion(def.binary) : undefined, + }; +} + +function buildCliPrompt(request: CompletionRequest): string { + const lines: string[] = []; + for (const message of request.messages) { + const content = + typeof message.content === 'string' + ? message.content + : message.content + .map((part) => (part.type === 'text' ? part.text : `[${part.type}]`)) + .join('\n'); + lines.push(`${message.role.toUpperCase()}: ${content}`); + } + + if (request.tools?.length) { + lines.push( + 'TOOLS AVAILABLE: OwnPilot tool calling is not available in CLI-backed runtime mode. Answer without invoking tool calls.' + ); + } + + return lines.join('\n\n'); +} + +function normalizeCliModel(model: string | undefined): string | undefined { + if (!model || model === 'default') return undefined; + return model; +} + +function parseCodexOutput(stdout: string): string { + let output = ''; + const lines = stdout.trim().split('\n'); + for (const line of lines) { + try { + const parsed = JSON.parse(line) as Record; + if (parsed.type === 'message' && parsed.role === 'assistant') { + output = String(parsed.content ?? ''); + } else if (parsed.content) { + output = String(parsed.content); + } + } catch { + if (line.trim()) output += line + '\n'; + } + } + + return output.trim() || stdout.trim(); +} + +async function collectStreamingProcess( + command: string, + args: string[], + env: Record, + cwd?: string +): Promise>> { + return new Promise((resolve) => { + const chunks: Array> = []; + const proc = spawn(command, args, { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + proc.stdout?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + if (text) { + chunks.push(ok({ id: crypto.randomUUID(), content: text, done: false })); + } + }); + + let stderr = ''; + proc.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + proc.on('error', (error) => { + chunks.push(err(new InternalError(String(error)))); + resolve(chunks); + }); + + proc.on('close', (code) => { + if ((code ?? 1) !== 0) { + chunks.push(err(new InternalError(stderr || `Process exited with code ${code ?? 1}`))); + } else { + chunks.push(ok({ id: crypto.randomUUID(), content: '', done: true })); + } + resolve(chunks); + }); + }); +} + +class CliRuntimeProvider implements IProvider { + readonly type: AIProvider = 'custom'; + + constructor( + private readonly def: CliRuntimeProviderDefinition, + private readonly apiKey?: string + ) {} + + isReady(): boolean { + return isBinaryInstalled(this.def.binary); + } + + async complete(request: CompletionRequest) { + if (!this.isReady()) { + return err(new ValidationError(`${this.def.displayName} CLI is not installed`)); + } + + const prompt = buildCliPrompt(request); + const env = this.apiKey + ? createSanitizedEnv(this.def.id, this.apiKey, this.def.envVar) + : createLoginOnlyCliEnv(this.def.id, this.def.envVar); + + try { + let output = ''; + if (this.def.id === 'claude-cli') { + const args = ['-p', prompt]; + const model = normalizeCliModel(request.model.model); + if (model) args.push('--model', model); + const result = await spawnCliProcess(this.def.binary, args, { + cwd: process.cwd(), + env, + timeout: 300_000, + }); + if (result.exitCode !== 0) { + return err(new InternalError(result.stderr || `Exited with code ${result.exitCode}`)); + } + output = result.stdout.trim(); + } else if (this.def.id === 'codex-cli') { + const args = ['exec', '--json', '--full-auto']; + const model = normalizeCliModel(request.model.model); + if (model) args.push('--model', model); + args.push(prompt); + const result = await spawnCliProcess(this.def.binary, args, { + cwd: process.cwd(), + env, + timeout: 300_000, + }); + if (result.exitCode !== 0) { + return err(new InternalError(result.stderr || `Exited with code ${result.exitCode}`)); + } + output = parseCodexOutput(result.stdout); + } else { + const args = ['-p', prompt, '--output-format', 'json']; + const model = normalizeCliModel(request.model.model); + if (model) args.push('--model', model); + const result = await spawnCliProcess(this.def.binary, args, { + cwd: process.cwd(), + env, + timeout: 300_000, + }); + if (result.exitCode !== 0) { + return err(new InternalError(result.stderr || `Exited with code ${result.exitCode}`)); + } + try { + const parsed = JSON.parse(result.stdout) as Record; + output = String(parsed.response ?? parsed.content ?? result.stdout).trim(); + } catch { + output = result.stdout.trim(); + } + } + + const response: CompletionResponse = { + id: crypto.randomUUID(), + content: output, + finishReason: 'stop', + model: normalizeCliModel(request.model.model) ?? this.def.defaultModel, + createdAt: new Date(), + }; + + return ok(response); + } catch (error) { + if (error instanceof TimeoutError) return err(error); + return err(new InternalError(error instanceof Error ? error.message : String(error))); + } + } + + async *stream(request: CompletionRequest) { + if (!this.isReady()) { + yield err(new InternalError(`${this.def.displayName} CLI is not installed`)); + return; + } + + const prompt = buildCliPrompt(request); + const env = this.apiKey + ? createSanitizedEnv(this.def.id, this.apiKey, this.def.envVar) + : createLoginOnlyCliEnv(this.def.id, this.def.envVar); + + let args: string[]; + if (this.def.id === 'claude-cli') { + args = ['-p', prompt]; + const model = normalizeCliModel(request.model.model); + if (model) args.push('--model', model); + } else if (this.def.id === 'codex-cli') { + args = ['exec', '--full-auto', prompt]; + const model = normalizeCliModel(request.model.model); + if (model) args.push('--model', model); + } else { + args = ['-p', prompt]; + const model = normalizeCliModel(request.model.model); + if (model) args.push('--model', model); + } + + const chunks = await collectStreamingProcess(this.def.binary, args, env, process.cwd()); + for (const chunk of chunks) { + yield chunk; + } + } + + countTokens(messages: readonly Message[]): number { + return Math.ceil( + messages.reduce((sum, msg) => { + const content = + typeof msg.content === 'string' + ? msg.content + : msg.content.map((part) => ('text' in part ? part.text : '')).join(''); + return sum + content.length; + }, 0) / 4 + ); + } + + async getModels() { + return ok([this.def.defaultModel]); + } +} + +class GatewayFallbackProvider implements IProvider { + readonly type: AIProvider; + + constructor(private readonly providers: IProvider[]) { + this.type = providers[0]?.type ?? 'custom'; + } + + isReady(): boolean { + return this.providers.some((provider) => provider.isReady()); + } + + async complete(request: CompletionRequest) { + let lastError: InternalError | TimeoutError | ValidationError | null = null; + for (const provider of this.providers) { + if (!provider.isReady()) continue; + const result = await provider.complete(request); + if (result.ok) return result; + lastError = result.error; + log.warn(`Provider fallback triggered from ${provider.type}: ${result.error.message}`); + } + return err(lastError ?? new InternalError('No providers are configured or ready')); + } + + async *stream(request: CompletionRequest) { + let yielded = false; + for (const provider of this.providers) { + if (!provider.isReady()) continue; + for await (const result of provider.stream(request)) { + if (!result.ok && !yielded) { + break; + } + if (result.ok) yielded = true; + yield result; + } + if (yielded) return; + } + yield err(new InternalError('No providers are configured or ready')); + } + + countTokens(messages: readonly Message[]): number { + return this.providers[0]?.countTokens(messages) ?? 0; + } + + async getModels() { + const models = new Set(); + for (const provider of this.providers) { + const result = await provider.getModels(); + if (result.ok) { + for (const model of result.value) models.add(model); + } + } + return ok([...models]); + } +} + +export async function createRuntimeProvider( + providerId: string, + fallbackProviderId?: string +): Promise { + const providers: IProvider[] = []; + + const primary = await createSingleRuntimeProvider(providerId); + if (primary) providers.push(primary); + + if (fallbackProviderId && fallbackProviderId !== providerId) { + const fallback = await createSingleRuntimeProvider(fallbackProviderId); + if (fallback) providers.push(fallback); + } else { + const familyFallbackId = getRuntimeFallbackProviderId(providerId); + if (familyFallbackId && familyFallbackId !== providerId) { + const fallback = await createSingleRuntimeProvider(familyFallbackId); + if (fallback) providers.push(fallback); + } + } + + if (providers.length === 0) return null; + if (providers.length === 1) return providers[0] ?? null; + return new GatewayFallbackProvider(providers); +} + +export async function resolveRuntimeProvider( + providerOverride?: string, + modelOverride?: string, + fallbackProviderId?: string +): Promise<{ providerId: string | null; model: string | null; instance: IProvider | null }> { + const resolved = await resolveProviderAndModel( + providerOverride ?? 'default', + modelOverride ?? 'default' + ); + + if (!resolved.provider || !resolved.model) { + return { providerId: null, model: null, instance: null }; + } + + const instance = await createRuntimeProvider(resolved.provider, fallbackProviderId); + return { + providerId: resolved.provider, + model: resolved.model, + instance, + }; +} + +async function createSingleRuntimeProvider(providerId: string): Promise { + if (isCliRuntimeProvider(providerId)) { + const def = getCliRuntimeProviderDefinition(providerId); + if (!def) return null; + + const apiKey = await getApiKey(providerId); + return new CliRuntimeProvider(def, apiKey); + } + + const localProvider = await localProvidersRepo.getProvider(providerId); + if (localProvider) { + return createProvider({ + provider: 'openai', + apiKey: localProvider.apiKey || 'local-no-key', + baseUrl: localProvider.baseUrl, + }); + } + + const apiKey = await getApiKey(providerId); + if (!apiKey) return null; + + const { getProviderConfig } = await import('@ownpilot/core'); + const config = getProviderConfig(providerId); + const providerType = [ + 'openai', + 'anthropic', + 'google', + ].includes(providerId) + ? providerId + : 'openai'; + + return createProvider({ + provider: providerType as ProviderConfig['provider'], + apiKey, + baseUrl: config?.baseUrl, + headers: config?.headers, + }); +} diff --git a/packages/gateway/src/services/workflow/node-executors.ts b/packages/gateway/src/services/workflow/node-executors.ts index 73930fdd..59c4dcc7 100644 --- a/packages/gateway/src/services/workflow/node-executors.ts +++ b/packages/gateway/src/services/workflow/node-executors.ts @@ -27,6 +27,7 @@ import { getErrorMessage } from '../../routes/helpers.js'; import { getLog } from '../log.js'; import { resolveTemplates } from './template-resolver.js'; import type { ToolExecutionResult } from './types.js'; +import { createRuntimeProvider, isCliRuntimeProvider } from '../model-execution.js'; import vm from 'node:vm'; const _log = getLog('WorkflowService'); @@ -140,9 +141,6 @@ export async function executeLlmNode( ? (resolveTemplates({ _sp: data.systemPrompt }, nodeOutputs, variables)._sp as string) : undefined; - // Lazy import to avoid circular deps (agent-cache is in routes/) - const { getProviderApiKey, loadProviderConfig, NATIVE_PROVIDERS } = - await import('../../routes/agent-cache.js'); const { resolveProviderAndModel } = await import('../../routes/settings.js'); // Resolve provider/model: empty or 'default' → user's configured defaults @@ -172,25 +170,41 @@ export async function executeLlmNode( effectiveModel = resolved.model ?? effectiveModel; } - // Resolve API key: use node-level override or stored key + const { getProviderApiKey, loadProviderConfig, NATIVE_PROVIDERS } = + await import('../../routes/agent-cache.js'); const apiKey = data.apiKey || (await getProviderApiKey(effectiveProvider)); - - // Resolve base URL and headers from provider config - let baseUrl = data.baseUrl; - const providerCfg = loadProviderConfig(effectiveProvider); - if (!baseUrl) { - if (providerCfg?.baseUrl) baseUrl = providerCfg.baseUrl; + if (!apiKey && !isCliRuntimeProvider(effectiveProvider)) { + return { + nodeId: node.id, + status: 'error', + error: `API key not configured for provider: ${effectiveProvider}`, + durationMs: Date.now() - startTime, + startedAt: new Date(startTime).toISOString(), + completedAt: new Date().toISOString(), + }; } - // Map non-native providers to openai-compatible - const providerType = NATIVE_PROVIDERS.has(effectiveProvider) ? effectiveProvider : 'openai'; - - const provider = createProvider({ - provider: providerType as ProviderConfig['provider'], - apiKey, - baseUrl, - headers: providerCfg?.headers, - }); + const provider = + data.apiKey || data.baseUrl + ? createProvider({ + provider: (NATIVE_PROVIDERS.has(effectiveProvider) + ? effectiveProvider + : 'openai') as ProviderConfig['provider'], + apiKey: apiKey ?? 'local-no-key', + baseUrl: data.baseUrl || loadProviderConfig(effectiveProvider)?.baseUrl, + headers: loadProviderConfig(effectiveProvider)?.headers, + }) + : await createRuntimeProvider(effectiveProvider); + if (!provider) { + return { + nodeId: node.id, + status: 'error', + error: `No runtime provider available for: ${effectiveProvider}`, + durationMs: Date.now() - startTime, + startedAt: new Date(startTime).toISOString(), + completedAt: new Date().toISOString(), + }; + } const messages: Array<{ role: 'system' | 'user'; content: string }> = []; if (resolvedSystemPrompt) { diff --git a/packages/ui/src/api/endpoints/index.ts b/packages/ui/src/api/endpoints/index.ts index a0277e54..6fa0ebfc 100644 --- a/packages/ui/src/api/endpoints/index.ts +++ b/packages/ui/src/api/endpoints/index.ts @@ -4,7 +4,7 @@ export { authApi } from './auth'; export type { AuthStatus, LoginResponse, PasswordResponse, SessionsResponse } from './auth'; -export { providersApi } from './providers'; +export { providersApi, localProviderManagementApi } from './providers'; export type { ProvidersListData, ProviderConfigData } from './providers'; export { modelsApi } from './models'; export { settingsApi, modelRoutingApi } from './settings'; diff --git a/packages/ui/src/api/endpoints/misc.ts b/packages/ui/src/api/endpoints/misc.ts index ab930f93..b4d4d55d 100644 --- a/packages/ui/src/api/endpoints/misc.ts +++ b/packages/ui/src/api/endpoints/misc.ts @@ -240,6 +240,7 @@ export const modelConfigsApi = { export const localProvidersApi = { list: () => apiClient.get('/local-providers'), templates: () => apiClient.get('/local-providers/templates'), + get: (id: string) => apiClient.get(`/local-providers/${id}`), create: (data: { name: string; providerType: string; @@ -247,6 +248,21 @@ export const localProvidersApi = { apiKey?: string; discoveryEndpoint?: string; }) => apiClient.post('/local-providers', data), + update: ( + id: string, + data: { + name?: string; + baseUrl?: string; + apiKey?: string; + discoveryEndpoint?: string; + isEnabled?: boolean; + } + ) => apiClient.put(`/local-providers/${id}`, data), + delete: (id: string) => apiClient.delete(`/local-providers/${id}`), + toggle: (id: string, enabled: boolean) => + apiClient.patch(`/local-providers/${id}/toggle`, { enabled }), + setDefault: (id: string) => apiClient.patch(`/local-providers/${id}/set-default`), + discover: (id: string) => apiClient.post(`/local-providers/${id}/discover`), models: (id: string) => apiClient.get>( `/local-providers/${id}/models` diff --git a/packages/ui/src/api/endpoints/providers.ts b/packages/ui/src/api/endpoints/providers.ts index a6ae7c8c..ceaaad87 100644 --- a/packages/ui/src/api/endpoints/providers.ts +++ b/packages/ui/src/api/endpoints/providers.ts @@ -32,3 +32,9 @@ export const providersApi = { `/providers/${id}/models` ), }; + +export const localProviderManagementApi = { + toggle: (id: string, enabled: boolean) => + apiClient.patch(`/local-providers/${id}/toggle`, { enabled }), + delete: (id: string) => apiClient.delete(`/local-providers/${id}`), +}; diff --git a/packages/ui/src/components/ProvidersTab.tsx b/packages/ui/src/components/ProvidersTab.tsx index f712e12c..8b2a8a57 100644 --- a/packages/ui/src/components/ProvidersTab.tsx +++ b/packages/ui/src/components/ProvidersTab.tsx @@ -7,9 +7,9 @@ import { useState, useEffect, useCallback } from 'react'; import { useDialog } from './ConfirmDialog'; -import { Check, Server, Edit2, Save, X, ExternalLink, Search } from './icons'; +import { Check, Server, Edit2, Save, X, ExternalLink, Search, Trash, AlertCircle } from './icons'; import { useToast } from './ToastProvider'; -import { providersApi } from '../api'; +import { localProviderManagementApi, providersApi } from '../api'; import type { ProviderInfo, UserOverride } from '../types'; // Provider type options - must match ProviderType in configs/types.ts @@ -20,6 +20,27 @@ const PROVIDER_TYPES = [ { value: 'google', label: 'Google Gemini (Native)' }, ]; +const CLI_REMOVAL_INFO: Record< + string, + { packageName: string; binary: string; uninstallCommand: string } +> = { + 'claude-cli': { + packageName: '@anthropic-ai/claude-code', + binary: 'claude', + uninstallCommand: 'npm uninstall -g @anthropic-ai/claude-code', + }, + 'codex-cli': { + packageName: '@openai/codex', + binary: 'codex', + uninstallCommand: 'npm uninstall -g @openai/codex', + }, + 'gemini-cli': { + packageName: '@google/gemini-cli', + binary: 'gemini', + uninstallCommand: 'npm uninstall -g @google/gemini-cli', + }, +}; + export function ProvidersTab() { const { confirm } = useDialog(); const toast = useToast(); @@ -44,6 +65,7 @@ export function ProvidersTab() { notes: '', }); const [saving, setSaving] = useState(false); + const [cliRemovalProviderId, setCliRemovalProviderId] = useState(null); const fetchProviders = useCallback(async () => { try { @@ -62,12 +84,24 @@ export function ProvidersTab() { }, [fetchProviders]); const handleToggle = async (providerId: string, enabled: boolean) => { + const provider = providers.find((p) => p.id === providerId); + if (!provider) return; + try { - await providersApi.toggle(providerId, enabled); - // Update local state - setProviders((prev) => - prev.map((p) => (p.id === providerId ? { ...p, isEnabled: enabled, hasOverride: true } : p)) - ); + if (provider.transport === 'local') { + await localProviderManagementApi.toggle(providerId, enabled); + await fetchProviders(); + } else if (provider.transport === 'cli') { + await providersApi.toggle(providerId, enabled); + await fetchProviders(); + } else { + await providersApi.toggle(providerId, enabled); + setProviders((prev) => + prev.map((p) => + p.id === providerId ? { ...p, isEnabled: enabled, hasOverride: true } : p + ) + ); + } toast.success(enabled ? 'Provider enabled' : 'Provider disabled'); } catch { toast.error('Failed to toggle provider'); @@ -75,6 +109,19 @@ export function ProvidersTab() { }; const handleEdit = async (providerId: string) => { + const provider = providers.find((p) => p.id === providerId); + if (!provider) return; + + if (provider.transport === 'cli') { + showCliProviderWarning(provider.name); + return; + } + + if (provider.transport === 'local') { + window.location.href = '/models'; + return; + } + // Fetch current config try { const data = await providersApi.getConfig(providerId); @@ -114,6 +161,25 @@ export function ProvidersTab() { }; const handleResetOverride = async (providerId: string) => { + const provider = providers.find((p) => p.id === providerId); + if (!provider) return; + + if (provider.transport === 'cli') { + try { + await providersApi.resetConfig(providerId); + await fetchProviders(); + toast.success('CLI provider restored in OwnPilot'); + } catch { + toast.error('Failed to restore CLI provider'); + } + return; + } + + if (provider.transport === 'local') { + toast.warning('Local providers do not have override reset here. Manage them from AI Models.'); + return; + } + if (!(await confirm({ message: 'Reset this provider to default settings?' }))) return; try { await providersApi.resetConfig(providerId); @@ -125,6 +191,31 @@ export function ProvidersTab() { } }; + const handleDeleteLocalProvider = async (providerId: string, providerName: string) => { + if ( + !(await confirm({ + message: `Delete local provider "${providerName}" and all its models?`, + variant: 'danger', + })) + ) { + return; + } + + try { + await localProviderManagementApi.delete(providerId); + await fetchProviders(); + toast.success('Local provider deleted'); + } catch { + toast.error('Failed to delete local provider'); + } + }; + + const showCliProviderWarning = (providerName: string) => { + toast.warning( + `${providerName} bir CLI runtime provider. Ayrıntılı config düzenlenemez; sadece OwnPilot içinde kaldırabilir veya sistemden uninstall edebilirsin.` + ); + }; + // Filter and search providers const filteredProviders = providers.filter((p) => { // Search filter @@ -140,12 +231,21 @@ export function ProvidersTab() { return true; }); - // Group by configured status - const configuredProviders = filteredProviders.filter((p) => p.isConfigured); - const unconfiguredProviders = filteredProviders.filter((p) => !p.isConfigured); + // Group by provider type/status + const cliProviders = filteredProviders.filter((p) => p.transport === 'cli'); + const manageableProviders = filteredProviders.filter((p) => p.transport !== 'cli'); + const configuredProviders = manageableProviders.filter((p) => p.isConfigured); + const unconfiguredProviders = manageableProviders.filter((p) => !p.isConfigured); + const cliRemovalProvider = cliRemovalProviderId + ? providers.find((p) => p.id === cliRemovalProviderId && p.transport === 'cli') + : null; + const cliRemovalInfo = cliRemovalProvider ? CLI_REMOVAL_INFO[cliRemovalProvider.id] : null; const renderProviderCard = (provider: ProviderInfo) => { const isDisabled = !provider.isEnabled; + const isCliProvider = provider.transport === 'cli'; + const isLocalProvider = provider.transport === 'local'; + const configuredLabel = isCliProvider ? 'Ready' : isLocalProvider ? 'Local' : 'API Key'; return (
- API Key + {configuredLabel} + + )} + {isCliProvider && ( + + CLI + + )} + {isLocalProvider && ( + + Local )} {provider.hasOverride && ( @@ -191,6 +301,25 @@ export function ProvidersTab() { {provider.baseUrl}

)} + {isCliProvider && ( +
+

+ + + Bu provider yüklü CLI'dan otomatik gelir. Bu sayfadan edit, toggle veya + silme desteklenmez. + +

+

+ API key gerekiyorsa API Keys sayfasından girin. Bu karttan OwnPilot içinde kaldırabilir, tamamen kaldırmak için uninstall bilgisini açabilirsin. +

+
+ )} + {isLocalProvider && ( +

+ Local providers are managed in AI Models. +

+ )}

{provider.modelCount} models {provider.features.vision && 👁 Vision} @@ -200,14 +329,19 @@ export function ProvidersTab() {

- {/* Enable/Disable toggle */} - {/* Edit button */} - + {!isCliProvider && !isLocalProvider && ( + + )} + + {isCliProvider && ( + <> + + + + + + + )} + + {isLocalProvider && ( + <> + + + + + + )} {/* Docs link */} {provider.docsUrl && ( @@ -263,6 +443,34 @@ export function ProvidersTab() {

+ {cliProviders.length > 0 && ( +
+

+ + + {cliProviders.length} CLI provider otomatik algılandı. Bunlar bu sayfadan + ayrıntılı düzenlenemez. Ama OwnPilot içinden gizleyebilir veya tamamen kaldırmak + için ilgili CLI'ı sistemden kaldırabilirsin; API key gerekiyorsa + {' '} + + API Keys + + {' '} + sayfasını kullanın. + +

+
+ )} + + {cliProviders.length > 0 && ( +
+

+ CLI Runtime Providers ({cliProviders.length}) +

+
{cliProviders.map(renderProviderCard)}
+
+ )} + {/* Search and filters */}
@@ -340,7 +548,62 @@ export function ProvidersTab() {
No providers match your search.
)} - {/* Edit Modal */} + {cliRemovalProvider && cliRemovalInfo && ( +
+
+
+
+

+ Remove CLI Provider: {cliRemovalProvider.name} +

+ +
+ +
+

+ Bu provider sistemde kurulu {cliRemovalInfo.binary} + {' '}binary'sinden otomatik algılanıyor. Tamamen kaldırmak için aşağıdaki komutu + kullan. +

+
+

Kaldırma komutu

+
+                    {cliRemovalInfo.uninstallCommand}
+                  
+
+
+

Alternatif olarak `{cliRemovalInfo.binary}` binary'sini `PATH` dışına alabilirsin.

+

Ardından gateway'i yeniden başlat. Provider listeden otomatik düşer.

+

OwnPilot içinde sadece gizlemek istiyorsan bu karttaki toggle'ı kapatman yeterli.

+

+ API key kullanıyorsan silmek için{' '} + + API Keys + {' '} + sayfasını kullan. +

+
+
+ +
+ +
+
+
+
+ )} + + {/* Edit Modal - HTTP providers only */} {editingProvider && (
diff --git a/packages/ui/src/components/ai-models/AIModelsTab.tsx b/packages/ui/src/components/ai-models/AIModelsTab.tsx index 7cf0a6cb..a19325a8 100644 --- a/packages/ui/src/components/ai-models/AIModelsTab.tsx +++ b/packages/ui/src/components/ai-models/AIModelsTab.tsx @@ -355,7 +355,7 @@ export function AIModelsTab() { ) return; try { - await apiClient.delete(`/local-providers/${providerId}`); + await localProvidersApi.delete(providerId); setSuccess(`Deleted ${name}`); setTimeout(() => setSuccess(null), 2000); await loadData(); @@ -367,7 +367,9 @@ export function AIModelsTab() { // Handle toggle local provider const handleToggleLocalProvider = async (providerId: string) => { try { - await apiClient.patch(`/local-providers/${providerId}/toggle`); + const provider = localProviders.find((lp) => lp.id === providerId); + if (!provider) return; + await localProvidersApi.toggle(providerId, !provider.isEnabled); await loadData(); } catch { setError('Failed to toggle provider'); @@ -377,7 +379,7 @@ export function AIModelsTab() { // Handle set default local provider const handleSetDefaultLocal = async (providerId: string) => { try { - await apiClient.patch(`/local-providers/${providerId}/set-default`); + await localProvidersApi.setDefault(providerId); setSuccess('Default provider updated'); setTimeout(() => setSuccess(null), 2000); await loadData(); diff --git a/packages/ui/src/pages/ApiKeysPage.tsx b/packages/ui/src/pages/ApiKeysPage.tsx index ac6799bc..535fda9f 100644 --- a/packages/ui/src/pages/ApiKeysPage.tsx +++ b/packages/ui/src/pages/ApiKeysPage.tsx @@ -241,6 +241,11 @@ export function ApiKeysPage() { }; const getProviderPlaceholder = (provider: ProviderConfig): string => { + if (provider.transport === 'cli') { + return provider.authMethod === 'login' + ? 'Login via installed CLI' + : 'Optional API key for CLI fallback'; + } // Use API-provided placeholder if available if (provider.apiKeyPlaceholder) { return provider.apiKeyPlaceholder; @@ -289,6 +294,7 @@ export function ApiKeysPage() { // Standard category order (matches PROVIDER_CATEGORIES in providers.ts) const categoryOrder = [ 'Popular', + 'CLI Providers', 'Cloud Platforms', 'Inference Providers', 'Search & Research', @@ -332,6 +338,13 @@ export function ApiKeysPage() { const renderProviderCard = (provider: ProviderConfig) => { const isConfigured = configuredProviders.includes(provider.id); const hasNewValue = apiKeys[provider.id] && apiKeys[provider.id]!.trim(); + const isCliProvider = provider.transport === 'cli'; + const helperText = + provider.authMethod === 'login' + ? 'Authenticated via local CLI login' + : provider.authMethod === 'both' + ? 'Works with local CLI login or an optional API key' + : 'Requires an API key'; return (
- {isConfigured && ( + {isConfigured && !isCliProvider && (
diff --git a/packages/ui/src/pages/CodingAgentsPage.tsx b/packages/ui/src/pages/CodingAgentsPage.tsx index 841e5b0c..de688836 100644 --- a/packages/ui/src/pages/CodingAgentsPage.tsx +++ b/packages/ui/src/pages/CodingAgentsPage.tsx @@ -503,7 +503,11 @@ function ProviderStatusCard({ status }: { status: CodingAgentStatus }) { {status.installed ? (status.version ?? 'Installed') : 'Not installed'} - {status.configured && } + {status.configured && ( + + )}
diff --git a/packages/ui/src/pages/ModelsPage.tsx b/packages/ui/src/pages/ModelsPage.tsx index 0c28f25f..558895be 100644 --- a/packages/ui/src/pages/ModelsPage.tsx +++ b/packages/ui/src/pages/ModelsPage.tsx @@ -38,7 +38,8 @@ interface ProviderInfo { docsUrl?: string; color: string; isConfigured: boolean; - configSource?: 'database' | 'environment' | null; + configSource?: 'database' | 'environment' | 'cli' | null; + transport?: 'http' | 'cli' | 'local'; } const CAPABILITY_ICONS: Record = { @@ -196,6 +197,13 @@ export function ModelsPage() { > ENV + ) : info?.configSource === 'cli' ? ( + + CLI + ) : ( Configured diff --git a/packages/ui/src/types/models.ts b/packages/ui/src/types/models.ts index c8ddcc3d..3183b03d 100644 --- a/packages/ui/src/types/models.ts +++ b/packages/ui/src/types/models.ts @@ -23,13 +23,19 @@ export interface ProviderInfo { name: string; type: string; baseUrl?: string; - apiKeyEnv: string; + apiKeyEnv?: string; docsUrl?: string; isConfigured: boolean; isEnabled: boolean; hasOverride: boolean; color?: string; modelCount: number; + configSource?: 'database' | 'environment' | 'cli' | null; + transport?: 'http' | 'cli' | 'local'; + authMethod?: 'api-key' | 'login' | 'both' | 'none'; + family?: string; + fallbackProviderId?: string; + version?: string; features: { streaming: boolean; toolUse: boolean; @@ -43,12 +49,18 @@ export interface ProviderInfo { export interface ProviderConfig { id: string; name: string; - apiKeyEnv: string; + apiKeyEnv?: string; baseUrl?: string; docsUrl?: string; models?: { id: string; name: string }[]; apiKeyPlaceholder?: string; color?: string; + transport?: 'http' | 'cli' | 'local'; + authMethod?: 'api-key' | 'login' | 'both' | 'none'; + configSource?: 'database' | 'environment' | 'cli' | null; + fallbackProviderId?: string; + family?: string; + version?: string; } /** User override for provider settings */