feat(desktop): add provider IPC handlers

- Add provider.ts with handlers for list, current, set, saveApiKey, importOAuth
- Import OAuth credentials from CLI tools (Claude Code, Codex)
- Register provider handlers in IPC index
- Expose provider API in preload.ts with TypeScript types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-04 18:25:13 +08:00
parent d7eb0da49b
commit 48245be52d
4 changed files with 390 additions and 0 deletions

View file

@ -101,6 +101,27 @@ interface LocalChatEvent {
}
}
interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
interface ElectronAPI {
hub: {
init: () => Promise<unknown>
@ -145,6 +166,16 @@ interface ElectronAPI {
updateStyle: (style: string) => Promise<unknown>
updateUser: (content: string) => Promise<unknown>
}
provider: {
list: () => Promise<ProviderStatus[]>
listAvailable: () => Promise<ProviderStatus[]>
current: () => Promise<CurrentProviderInfo>
set: (providerId: string, modelId?: string) => Promise<{ ok: boolean; provider?: string; model?: string; error?: string }>
getMeta: (providerId: string) => Promise<unknown>
isAvailable: (providerId: string) => Promise<boolean>
saveApiKey: (providerId: string, apiKey: string) => Promise<{ ok: boolean; error?: string }>
importOAuth: (providerId: string) => Promise<{ ok: boolean; expiresAt?: number; error?: string }>
}
localChat: {
subscribe: (agentId: string) => Promise<{ ok?: boolean; error?: string; alreadySubscribed?: boolean }>
unsubscribe: (agentId: string) => Promise<{ ok: boolean }>

View file

@ -5,11 +5,13 @@ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
export { registerSkillsIpcHandlers } from './skills.js'
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js'
export { registerProfileIpcHandlers } from './profile.js'
export { registerProviderIpcHandlers } from './provider.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
import { registerSkillsIpcHandlers } from './skills.js'
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
import { registerProfileIpcHandlers } from './profile.js'
import { registerProviderIpcHandlers } from './provider.js'
/**
* Register all IPC handlers.
@ -20,6 +22,7 @@ export function registerAllIpcHandlers(): void {
registerAgentIpcHandlers()
registerSkillsIpcHandlers()
registerProfileIpcHandlers()
registerProviderIpcHandlers()
}
/**

View file

@ -0,0 +1,312 @@
/**
* Provider IPC handlers for Electron main process.
*
* Manages LLM provider listing, status checking, and switching.
* Mirrors the CLI `/provider` command functionality.
*/
import { ipcMain } from 'electron'
import { getCurrentHub } from './hub.js'
import {
getProviderList,
getAvailableProviders,
getCurrentProvider,
getProviderMeta,
isProviderAvailable,
getLoginInstructions,
type ProviderInfo,
} from '../../../../src/agent/providers/index.js'
import {
readClaudeCliCredentials,
readCodexCliCredentials,
} from '../../../../src/agent/providers/oauth/cli-credentials.js'
import { credentialManager } from '../../../../src/agent/credentials.js'
/**
* Provider info returned to renderer (matches ProviderInfo from registry).
*/
export interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
/**
* Current provider/model info returned to renderer.
*/
export interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
/**
* Get the default agent from Hub.
*/
function getDefaultAgent() {
const hub = getCurrentHub()
if (!hub) return null
const agentIds = hub.listAgents()
if (agentIds.length === 0) return null
return hub.getAgent(agentIds[0]) ?? null
}
/**
* Register all Provider-related IPC handlers.
*/
export function registerProviderIpcHandlers(): void {
/**
* List all providers with their status.
* This is the main listing function, similar to CLI `/provider` command.
*/
ipcMain.handle('provider:list', async (): Promise<ProviderStatus[]> => {
const providers = getProviderList()
return providers.map((p: ProviderInfo) => ({
id: p.id,
name: p.name,
authMethod: p.authMethod,
available: p.available,
configured: p.configured,
current: p.current,
defaultModel: p.defaultModel,
models: p.models,
loginUrl: p.loginUrl,
loginCommand: p.loginCommand,
loginInstructions: getLoginInstructions(p.id),
}))
})
/**
* List only available (configured) providers.
*/
ipcMain.handle('provider:listAvailable', async (): Promise<ProviderStatus[]> => {
const providers = getAvailableProviders()
return providers.map((p: ProviderInfo) => ({
id: p.id,
name: p.name,
authMethod: p.authMethod,
available: p.available,
configured: p.configured,
current: p.current,
defaultModel: p.defaultModel,
models: p.models,
loginUrl: p.loginUrl,
loginCommand: p.loginCommand,
loginInstructions: getLoginInstructions(p.id),
}))
})
/**
* Get current provider and model from the active agent.
*/
ipcMain.handle('provider:current', async (): Promise<CurrentProviderInfo> => {
const agent = getDefaultAgent()
if (agent) {
// Get from actual agent instance
const info = agent.getProviderInfo()
const meta = getProviderMeta(info.provider)
return {
provider: info.provider,
model: info.model,
providerName: meta?.name,
available: isProviderAvailable(info.provider),
}
}
// Fallback to credentials default
const defaultProvider = getCurrentProvider()
const meta = getProviderMeta(defaultProvider)
return {
provider: defaultProvider,
model: meta?.defaultModel,
providerName: meta?.name,
available: isProviderAvailable(defaultProvider),
}
})
/**
* Switch the agent to a different provider and/or model.
*/
ipcMain.handle(
'provider:set',
async (_event, providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> => {
const agent = getDefaultAgent()
if (!agent) {
return { ok: false, error: 'No agent available' }
}
// Validate provider exists
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
// Check if provider is available
if (!isProviderAvailable(providerId)) {
const instructions = getLoginInstructions(providerId)
return {
ok: false,
error: `Provider "${providerId}" is not configured.\n${instructions}`,
}
}
// Validate model if specified
if (modelId && !meta.models.includes(modelId)) {
return {
ok: false,
error: `Model "${modelId}" is not available for provider "${providerId}". Available: ${meta.models.join(', ')}`,
}
}
try {
const result = agent.setProvider(providerId, modelId)
console.log(`[IPC] Provider switched to: ${result.provider}, model: ${result.model}`)
return {
ok: true,
provider: result.provider,
model: result.model,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to switch provider: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Get metadata for a specific provider.
*/
ipcMain.handle('provider:getMeta', async (_event, providerId: string) => {
const meta = getProviderMeta(providerId)
if (!meta) {
return { error: `Unknown provider: ${providerId}` }
}
return {
id: meta.id,
name: meta.name,
authMethod: meta.authMethod,
defaultModel: meta.defaultModel,
models: meta.models,
loginUrl: meta.loginUrl,
loginCommand: meta.loginCommand,
available: isProviderAvailable(providerId),
loginInstructions: getLoginInstructions(providerId),
}
})
/**
* Check if a specific provider is available (has valid credentials).
*/
ipcMain.handle('provider:isAvailable', async (_event, providerId: string): Promise<boolean> => {
return isProviderAvailable(providerId)
})
/**
* Save API key for a provider to credentials.json5.
* After saving, the provider should become available.
*/
ipcMain.handle(
'provider:saveApiKey',
async (_event, providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> => {
try {
// Validate provider exists and uses API key auth
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (meta.authMethod !== 'api-key') {
return { ok: false, error: `Provider "${providerId}" uses ${meta.authMethod} authentication, not API key` }
}
// Save the API key
credentialManager.setLlmProviderApiKey(providerId, apiKey)
console.log(`[IPC] API key saved for provider: ${providerId}`)
return { ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to save API key: ${message}`)
return { ok: false, error: message }
}
}
)
/**
* Import OAuth credentials from CLI tools (claude-code, codex).
* Reads from CLI credential storage and saves to credentials.json5.
*/
ipcMain.handle(
'provider:importOAuth',
async (_event, providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> => {
try {
const meta = getProviderMeta(providerId)
if (!meta) {
return { ok: false, error: `Unknown provider: ${providerId}` }
}
if (meta.authMethod !== 'oauth') {
return { ok: false, error: `Provider "${providerId}" does not use OAuth authentication` }
}
// Read credentials from CLI tool
if (providerId === 'claude-code') {
const creds = readClaudeCliCredentials()
if (!creds) {
return { ok: false, error: 'No Claude Code credentials found. Run "claude login" first.' }
}
if (creds.expires <= Date.now()) {
return { ok: false, error: 'Claude Code credentials have expired. Run "claude login" again.' }
}
// Save to credentials.json5
const token = creds.type === 'oauth' ? creds.access : creds.token
const refreshToken = creds.type === 'oauth' ? creds.refresh : undefined
credentialManager.setLlmProviderOAuthToken(providerId, token, refreshToken, creds.expires)
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
return { ok: true, expiresAt: creds.expires }
}
if (providerId === 'openai-codex') {
const creds = readCodexCliCredentials()
if (!creds) {
return { ok: false, error: 'No Codex credentials found. Run "codex login" first.' }
}
if (creds.expires <= Date.now()) {
return { ok: false, error: 'Codex credentials have expired. Run "codex login" again.' }
}
// Save to credentials.json5
credentialManager.setLlmProviderOAuthToken(providerId, creds.access, creds.refresh, creds.expires)
console.log(`[IPC] OAuth credentials imported for: ${providerId}`)
return { ok: true, expiresAt: creds.expires }
}
return { ok: false, error: `OAuth import not supported for provider: ${providerId}` }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[IPC] Failed to import OAuth credentials: ${message}`)
return { ok: false, error: message }
}
}
)
}

View file

@ -44,6 +44,27 @@ export interface ProfileData {
userContent: string | undefined
}
export interface ProviderStatus {
id: string
name: string
authMethod: 'api-key' | 'oauth'
available: boolean
configured: boolean
current: boolean
defaultModel: string
models: string[]
loginUrl?: string
loginCommand?: string
loginInstructions?: string
}
export interface CurrentProviderInfo {
provider: string
model: string | undefined
providerName: string | undefined
available: boolean
}
// Local chat event types (for direct IPC communication without Gateway)
export interface LocalChatEvent {
agentId: string
@ -134,6 +155,29 @@ const electronAPI = {
updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content),
},
// Provider management
provider: {
/** List all providers with their status */
list: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:list'),
/** List only available (configured) providers */
listAvailable: (): Promise<ProviderStatus[]> => ipcRenderer.invoke('provider:listAvailable'),
/** Get current provider and model from the active agent */
current: (): Promise<CurrentProviderInfo> => ipcRenderer.invoke('provider:current'),
/** Switch the agent to a different provider and/or model */
set: (providerId: string, modelId?: string): Promise<{ ok: boolean; provider?: string; model?: string; error?: string }> =>
ipcRenderer.invoke('provider:set', providerId, modelId),
/** Get metadata for a specific provider */
getMeta: (providerId: string) => ipcRenderer.invoke('provider:getMeta', providerId),
/** Check if a specific provider is available */
isAvailable: (providerId: string): Promise<boolean> => ipcRenderer.invoke('provider:isAvailable', providerId),
/** Save API key for a provider */
saveApiKey: (providerId: string, apiKey: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('provider:saveApiKey', providerId, apiKey),
/** Import OAuth credentials from CLI tools (claude-code, codex) */
importOAuth: (providerId: string): Promise<{ ok: boolean; expiresAt?: number; error?: string }> =>
ipcRenderer.invoke('provider:importOAuth', providerId),
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */