diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index ced2a1b..2db3489 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -54,6 +54,7 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { applyProxySettings } from './proxy'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; +import { getProviderService } from '../services/providers/provider-service'; /** * For custom/ollama providers, derive a unique key for OpenClaw config files @@ -995,6 +996,8 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { * Provider-related IPC handlers */ function registerProviderHandlers(gatewayManager: GatewayManager): void { + const providerService = getProviderService(); + // Listen for OAuth success to automatically restart the Gateway with new tokens/configs. // Use a longer debounce (8s) so that provider:setDefault — which writes the full config // and then calls debouncedRestart(2s) — has time to fire and coalesce into a single @@ -1010,6 +1013,19 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { return await getAllProvidersWithKeyInfo(); }); + // New provider-service endpoints used by the account-based refactor. + ipcMain.handle('provider:listVendors', async () => { + return await providerService.listVendors(); + }); + + ipcMain.handle('provider:listAccounts', async () => { + return await providerService.listAccounts(); + }); + + ipcMain.handle('provider:getAccount', async (_, accountId: string) => { + return await providerService.getAccount(accountId); + }); + // Get a specific provider ipcMain.handle('provider:get', async (_, providerId: string) => { return await getProvider(providerId); diff --git a/electron/services/providers/provider-migration.ts b/electron/services/providers/provider-migration.ts new file mode 100644 index 0000000..83b2989 --- /dev/null +++ b/electron/services/providers/provider-migration.ts @@ -0,0 +1,35 @@ +import type { ProviderConfig } from '../../shared/providers/types'; +import { + getDefaultProviderAccountId, + providerConfigToAccount, + saveProviderAccount, +} from './provider-store'; +import { getClawXProviderStore } from './store-instance'; + +const PROVIDER_STORE_SCHEMA_VERSION = 1; + +export async function ensureProviderStoreMigrated(): Promise { + const store = await getClawXProviderStore(); + const schemaVersion = Number(store.get('schemaVersion') ?? 0); + + if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) { + return; + } + + const legacyProviders = (store.get('providers') ?? {}) as Record; + const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null; + const existingDefaultAccountId = await getDefaultProviderAccountId(); + + for (const provider of Object.values(legacyProviders)) { + const account = providerConfigToAccount(provider, { + isDefault: provider.id === defaultProviderId, + }); + await saveProviderAccount(account); + } + + if (!existingDefaultAccountId && defaultProviderId) { + store.set('defaultProviderAccountId', defaultProviderId); + } + + store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION); +} diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts new file mode 100644 index 0000000..b59a8ef --- /dev/null +++ b/electron/services/providers/provider-service.ts @@ -0,0 +1,61 @@ +import { + PROVIDER_DEFINITIONS, + getProviderDefinition, +} from '../../shared/providers/registry'; +import type { + ProviderAccount, + ProviderConfig, + ProviderDefinition, +} from '../../shared/providers/types'; +import { ensureProviderStoreMigrated } from './provider-migration'; +import { + getDefaultProviderAccountId, + getProviderAccount, + listProviderAccounts, + providerConfigToAccount, + saveProviderAccount, + setDefaultProviderAccount, +} from './provider-store'; + +export class ProviderService { + async listVendors(): Promise { + return PROVIDER_DEFINITIONS; + } + + async listAccounts(): Promise { + await ensureProviderStoreMigrated(); + return listProviderAccounts(); + } + + async getAccount(accountId: string): Promise { + await ensureProviderStoreMigrated(); + return getProviderAccount(accountId); + } + + async getDefaultAccountId(): Promise { + await ensureProviderStoreMigrated(); + return getDefaultProviderAccountId(); + } + + async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise { + await ensureProviderStoreMigrated(); + const account = providerConfigToAccount(config, options); + await saveProviderAccount(account); + return account; + } + + async setDefaultAccount(accountId: string): Promise { + await ensureProviderStoreMigrated(); + await setDefaultProviderAccount(accountId); + } + + getVendorDefinition(vendorId: string): ProviderDefinition | undefined { + return getProviderDefinition(vendorId); + } +} + +const providerService = new ProviderService(); + +export function getProviderService(): ProviderService { + return providerService; +} diff --git a/electron/services/providers/provider-store.ts b/electron/services/providers/provider-store.ts new file mode 100644 index 0000000..287f58c --- /dev/null +++ b/electron/services/providers/provider-store.ts @@ -0,0 +1,103 @@ +import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types'; +import { getProviderDefinition } from '../../shared/providers/registry'; +import { getClawXProviderStore } from './store-instance'; + +const PROVIDER_STORE_SCHEMA_VERSION = 1; + +function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] { + if (type === 'ollama') { + return 'local'; + } + + const definition = getProviderDefinition(type); + if (definition?.defaultAuthMode) { + return definition.defaultAuthMode; + } + + return 'api_key'; +} + +export function providerConfigToAccount( + config: ProviderConfig, + options?: { isDefault?: boolean }, +): ProviderAccount { + return { + id: config.id, + vendorId: config.type, + label: config.name, + authMode: inferAuthMode(config.type), + baseUrl: config.baseUrl, + apiProtocol: config.type === 'custom' || config.type === 'ollama' + ? 'openai-completions' + : getProviderDefinition(config.type)?.providerConfig?.api, + model: config.model, + fallbackModels: config.fallbackModels, + fallbackAccountIds: config.fallbackProviderIds, + enabled: config.enabled, + isDefault: options?.isDefault ?? false, + createdAt: config.createdAt, + updatedAt: config.updatedAt, + }; +} + +export function providerAccountToConfig(account: ProviderAccount): ProviderConfig { + return { + id: account.id, + name: account.label, + type: account.vendorId, + baseUrl: account.baseUrl, + model: account.model, + fallbackModels: account.fallbackModels, + fallbackProviderIds: account.fallbackAccountIds, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; +} + +export async function listProviderAccounts(): Promise { + const store = await getClawXProviderStore(); + const accounts = store.get('providerAccounts') as Record | undefined; + return Object.values(accounts ?? {}); +} + +export async function getProviderAccount(accountId: string): Promise { + const store = await getClawXProviderStore(); + const accounts = store.get('providerAccounts') as Record | undefined; + return accounts?.[accountId] ?? null; +} + +export async function saveProviderAccount(account: ProviderAccount): Promise { + const store = await getClawXProviderStore(); + const accounts = (store.get('providerAccounts') ?? {}) as Record; + accounts[account.id] = account; + store.set('providerAccounts', accounts); + store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION); +} + +export async function deleteProviderAccount(accountId: string): Promise { + const store = await getClawXProviderStore(); + const accounts = (store.get('providerAccounts') ?? {}) as Record; + delete accounts[accountId]; + store.set('providerAccounts', accounts); + + if (store.get('defaultProviderAccountId') === accountId) { + store.delete('defaultProviderAccountId'); + } +} + +export async function setDefaultProviderAccount(accountId: string): Promise { + const store = await getClawXProviderStore(); + store.set('defaultProviderAccountId', accountId); + + const accounts = (store.get('providerAccounts') ?? {}) as Record; + for (const account of Object.values(accounts)) { + account.isDefault = account.id === accountId; + } + store.set('providerAccounts', accounts); +} + +export async function getDefaultProviderAccountId(): Promise { + const store = await getClawXProviderStore(); + return store.get('defaultProviderAccountId') as string | undefined; +} diff --git a/electron/services/providers/store-instance.ts b/electron/services/providers/store-instance.ts new file mode 100644 index 0000000..1da129b --- /dev/null +++ b/electron/services/providers/store-instance.ts @@ -0,0 +1,23 @@ +// Lazy-load electron-store (ESM module) from the main process only. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let providerStore: any = null; + +export async function getClawXProviderStore() { + if (!providerStore) { + const Store = (await import('electron-store')).default; + providerStore = new Store({ + name: 'clawx-providers', + defaults: { + schemaVersion: 0, + providers: {} as Record, + providerAccounts: {} as Record, + apiKeys: {} as Record, + providerSecrets: {} as Record, + defaultProvider: null as string | null, + defaultProviderAccountId: null as string | null, + }, + }); + } + + return providerStore; +} diff --git a/electron/services/secrets/secret-store.ts b/electron/services/secrets/secret-store.ts new file mode 100644 index 0000000..ca9afec --- /dev/null +++ b/electron/services/secrets/secret-store.ts @@ -0,0 +1,82 @@ +import type { ProviderSecret } from '../../shared/providers/types'; +import { getClawXProviderStore } from '../providers/store-instance'; + +export interface SecretStore { + get(accountId: string): Promise; + set(secret: ProviderSecret): Promise; + delete(accountId: string): Promise; +} + +export class ElectronStoreSecretStore implements SecretStore { + async get(accountId: string): Promise { + const store = await getClawXProviderStore(); + const secrets = (store.get('providerSecrets') ?? {}) as Record; + const secret = secrets[accountId]; + if (secret) { + return secret; + } + + const apiKeys = (store.get('apiKeys') ?? {}) as Record; + const apiKey = apiKeys[accountId]; + if (!apiKey) { + return null; + } + + return { + type: 'api_key', + accountId, + apiKey, + }; + } + + async set(secret: ProviderSecret): Promise { + const store = await getClawXProviderStore(); + const secrets = (store.get('providerSecrets') ?? {}) as Record; + secrets[secret.accountId] = secret; + store.set('providerSecrets', secrets); + + // Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets. + const apiKeys = (store.get('apiKeys') ?? {}) as Record; + if (secret.type === 'api_key') { + apiKeys[secret.accountId] = secret.apiKey; + } else if (secret.type === 'local') { + if (secret.apiKey) { + apiKeys[secret.accountId] = secret.apiKey; + } else { + delete apiKeys[secret.accountId]; + } + } else { + delete apiKeys[secret.accountId]; + } + store.set('apiKeys', apiKeys); + } + + async delete(accountId: string): Promise { + const store = await getClawXProviderStore(); + const secrets = (store.get('providerSecrets') ?? {}) as Record; + delete secrets[accountId]; + store.set('providerSecrets', secrets); + + const apiKeys = (store.get('apiKeys') ?? {}) as Record; + delete apiKeys[accountId]; + store.set('apiKeys', apiKeys); + } +} + +const secretStore = new ElectronStoreSecretStore(); + +export function getSecretStore(): SecretStore { + return secretStore; +} + +export async function getProviderSecret(accountId: string): Promise { + return getSecretStore().get(accountId); +} + +export async function setProviderSecret(secret: ProviderSecret): Promise { + await getSecretStore().set(secret); +} + +export async function deleteProviderSecret(accountId: string): Promise { + await getSecretStore().delete(accountId); +} diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts new file mode 100644 index 0000000..78f81ba --- /dev/null +++ b/electron/shared/providers/registry.ts @@ -0,0 +1,294 @@ +import type { + ProviderBackendConfig, + ProviderDefinition, + ProviderType, + ProviderTypeInfo, +} from './types'; + +export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ + { + id: 'anthropic', + name: 'Anthropic', + icon: '🤖', + placeholder: 'sk-ant-api03-...', + model: 'Claude', + requiresApiKey: true, + category: 'official', + envVar: 'ANTHROPIC_API_KEY', + defaultModelId: 'claude-opus-4-6', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + }, + { + id: 'openai', + name: 'OpenAI', + icon: '💚', + placeholder: 'sk-proj-...', + model: 'GPT', + requiresApiKey: true, + category: 'official', + envVar: 'OPENAI_API_KEY', + defaultModelId: 'gpt-5.2', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-responses', + apiKeyEnv: 'OPENAI_API_KEY', + }, + }, + { + id: 'google', + name: 'Google', + icon: '🔷', + placeholder: 'AIza...', + model: 'Gemini', + requiresApiKey: true, + category: 'official', + envVar: 'GEMINI_API_KEY', + defaultModelId: 'gemini-3.1-pro-preview', + isOAuth: true, + supportsApiKey: true, + supportedAuthModes: ['api_key', 'oauth_browser'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + }, + { + id: 'openrouter', + name: 'OpenRouter', + icon: '🌐', + placeholder: 'sk-or-v1-...', + model: 'Multi-Model', + requiresApiKey: true, + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'anthropic/claude-opus-4.6', + defaultModelId: 'anthropic/claude-opus-4.6', + category: 'compatible', + envVar: 'OPENROUTER_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://openrouter.ai/api/v1', + api: 'openai-completions', + apiKeyEnv: 'OPENROUTER_API_KEY', + headers: { + 'HTTP-Referer': 'https://claw-x.com', + 'X-Title': 'ClawX', + }, + }, + }, + { + id: 'ark', + name: 'ByteDance Ark', + icon: 'A', + placeholder: 'your-ark-api-key', + model: 'Doubao', + requiresApiKey: true, + defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: 'ep-20260228000000-xxxxx', + category: 'official', + envVar: 'ARK_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + api: 'openai-completions', + apiKeyEnv: 'ARK_API_KEY', + }, + }, + { + id: 'moonshot', + name: 'Moonshot (CN)', + icon: '🌙', + placeholder: 'sk-...', + model: 'Kimi', + requiresApiKey: true, + defaultBaseUrl: 'https://api.moonshot.cn/v1', + defaultModelId: 'kimi-k2.5', + category: 'official', + envVar: 'MOONSHOT_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + apiKeyEnv: 'MOONSHOT_API_KEY', + models: [ + { + id: 'kimi-k2.5', + name: 'Kimi K2.5', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192, + }, + ], + }, + }, + { + id: 'siliconflow', + name: 'SiliconFlow (CN)', + icon: '🌊', + placeholder: 'sk-...', + model: 'Multi-Model', + requiresApiKey: true, + defaultBaseUrl: 'https://api.siliconflow.cn/v1', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', + defaultModelId: 'deepseek-ai/DeepSeek-V3', + category: 'compatible', + envVar: 'SILICONFLOW_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.siliconflow.cn/v1', + api: 'openai-completions', + apiKeyEnv: 'SILICONFLOW_API_KEY', + }, + }, + { + id: 'minimax-portal', + name: 'MiniMax (Global)', + icon: '☁️', + placeholder: 'sk-...', + model: 'MiniMax', + requiresApiKey: false, + isOAuth: true, + supportsApiKey: true, + defaultModelId: 'MiniMax-M2.5', + apiKeyUrl: 'https://intl.minimaxi.com/', + category: 'official', + envVar: 'MINIMAX_API_KEY', + supportedAuthModes: ['oauth_device', 'api_key'], + defaultAuthMode: 'oauth_device', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.minimax.io/anthropic', + api: 'anthropic-messages', + apiKeyEnv: 'MINIMAX_API_KEY', + }, + }, + { + id: 'minimax-portal-cn', + name: 'MiniMax (CN)', + icon: '☁️', + placeholder: 'sk-...', + model: 'MiniMax', + requiresApiKey: false, + isOAuth: true, + supportsApiKey: true, + defaultModelId: 'MiniMax-M2.5', + apiKeyUrl: 'https://platform.minimaxi.com/', + category: 'official', + envVar: 'MINIMAX_CN_API_KEY', + supportedAuthModes: ['oauth_device', 'api_key'], + defaultAuthMode: 'oauth_device', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.minimaxi.com/anthropic', + api: 'anthropic-messages', + apiKeyEnv: 'MINIMAX_CN_API_KEY', + }, + }, + { + id: 'qwen-portal', + name: 'Qwen', + icon: '☁️', + placeholder: 'sk-...', + model: 'Qwen', + requiresApiKey: false, + isOAuth: true, + defaultModelId: 'coder-model', + category: 'official', + envVar: 'QWEN_API_KEY', + supportedAuthModes: ['oauth_device'], + defaultAuthMode: 'oauth_device', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://portal.qwen.ai/v1', + api: 'openai-completions', + apiKeyEnv: 'QWEN_API_KEY', + }, + }, + { + id: 'ollama', + name: 'Ollama', + icon: '🦙', + placeholder: 'Not required', + requiresApiKey: false, + defaultBaseUrl: 'http://localhost:11434/v1', + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: 'qwen3:latest', + category: 'local', + supportedAuthModes: ['local'], + defaultAuthMode: 'local', + supportsMultipleAccounts: true, + }, + { + id: 'custom', + name: 'Custom', + icon: '⚙️', + placeholder: 'API key...', + requiresApiKey: true, + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: 'your-provider/model-id', + category: 'custom', + envVar: 'CUSTOM_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + }, +]; + +const PROVIDER_DEFINITION_MAP = new Map( + PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), +); + +export function getProviderDefinition( + type: ProviderType | string, +): ProviderDefinition | undefined { + return PROVIDER_DEFINITION_MAP.get(type as ProviderType); +} + +export function getProviderTypeInfo( + type: ProviderType, +): ProviderTypeInfo | undefined { + return getProviderDefinition(type); +} + +export function getProviderEnvVar(type: string): string | undefined { + return getProviderDefinition(type)?.envVar; +} + +export function getProviderDefaultModel(type: string): string | undefined { + return getProviderDefinition(type)?.defaultModelId; +} + +export function getProviderBackendConfig( + type: string, +): ProviderBackendConfig | undefined { + return getProviderDefinition(type)?.providerConfig; +} + +export function getProviderUiInfoList(): ProviderTypeInfo[] { + return PROVIDER_DEFINITIONS; +} + +export function getKeyableProviderTypes(): string[] { + return PROVIDER_DEFINITIONS.filter((definition) => definition.envVar).map( + (definition) => definition.id, + ); +} diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts new file mode 100644 index 0000000..2cda0a9 --- /dev/null +++ b/electron/shared/providers/types.ts @@ -0,0 +1,169 @@ +export const PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'ark', + 'moonshot', + 'siliconflow', + 'minimax-portal', + 'minimax-portal-cn', + 'qwen-portal', + 'ollama', + 'custom', +] as const; + +export const BUILTIN_PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'ark', + 'moonshot', + 'siliconflow', + 'minimax-portal', + 'minimax-portal-cn', + 'qwen-portal', + 'ollama', +] as const; + +export type ProviderType = (typeof PROVIDER_TYPES)[number]; +export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; + +export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local'; + +export type ProviderProtocol = + | 'openai-completions' + | 'openai-responses' + | 'anthropic-messages'; + +export type ProviderAuthMode = + | 'api_key' + | 'oauth_device' + | 'oauth_browser' + | 'local'; + +export type ProviderVendorCategory = + | 'official' + | 'compatible' + | 'local' + | 'custom'; + +export interface ProviderConfig { + id: string; + name: string; + type: ProviderType; + baseUrl?: string; + model?: string; + fallbackModels?: string[]; + fallbackProviderIds?: string[]; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ProviderWithKeyInfo extends ProviderConfig { + hasKey: boolean; + keyMasked: string | null; +} + +export interface ProviderTypeInfo { + id: ProviderType; + name: string; + icon: string; + placeholder: string; + model?: string; + requiresApiKey: boolean; + defaultBaseUrl?: string; + showBaseUrl?: boolean; + showModelId?: boolean; + showModelIdInDevModeOnly?: boolean; + modelIdPlaceholder?: string; + defaultModelId?: string; + isOAuth?: boolean; + supportsApiKey?: boolean; + apiKeyUrl?: string; +} + +export interface ProviderModelEntry extends Record { + id: string; + name: string; +} + +export interface ProviderBackendConfig { + baseUrl: string; + api: ProviderProtocol; + apiKeyEnv: string; + models?: ProviderModelEntry[]; + headers?: Record; +} + +export interface ProviderDefinition extends ProviderTypeInfo { + category: ProviderVendorCategory; + envVar?: string; + providerConfig?: ProviderBackendConfig; + supportedAuthModes: ProviderAuthMode[]; + defaultAuthMode: ProviderAuthMode; + supportsMultipleAccounts: boolean; +} + +export interface ProviderAccount { + id: string; + vendorId: ProviderType; + label: string; + authMode: ProviderAuthMode; + baseUrl?: string; + apiProtocol?: ProviderProtocol; + model?: string; + fallbackModels?: string[]; + fallbackAccountIds?: string[]; + enabled: boolean; + isDefault: boolean; + metadata?: { + region?: string; + email?: string; + resourceUrl?: string; + customModels?: string[]; + }; + createdAt: string; + updatedAt: string; +} + +export type ProviderSecret = + | { + type: 'api_key'; + accountId: string; + apiKey: string; + } + | { + type: 'oauth'; + accountId: string; + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + email?: string; + subject?: string; + } + | { + type: 'local'; + accountId: string; + apiKey?: string; + }; + +export interface ModelSummary { + id: string; + name: string; + vendorId: string; + accountId?: string; + supportsVision?: boolean; + supportsReasoning?: boolean; + contextWindow?: number; + pricing?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; + source: 'builtin' | 'remote' | 'gateway' | 'custom'; +} diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index 1d4904d..bad00dd 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -1,147 +1,25 @@ /** - * Provider Registry — single source of truth for backend provider metadata. - * Centralizes env var mappings, default models, and OpenClaw provider configs. - * - * NOTE: When adding a new provider type, also update src/lib/providers.ts + * Backend compatibility layer around the shared provider registry. */ -export const BUILTIN_PROVIDER_TYPES = [ - 'anthropic', - 'openai', - 'google', - 'openrouter', - 'ark', - 'moonshot', - 'siliconflow', - 'minimax-portal', - 'minimax-portal-cn', - 'qwen-portal', - 'ollama', -] as const; -export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; -export type ProviderType = BuiltinProviderType | 'custom'; +export { + BUILTIN_PROVIDER_TYPES, + type BuiltinProviderType, + type ProviderType, +} from '../shared/providers/types'; +import { + type ProviderBackendConfig, + type ProviderModelEntry, +} from '../shared/providers/types'; +import { + getKeyableProviderTypes as getSharedKeyableProviderTypes, + getProviderBackendConfig, + getProviderDefaultModel as getSharedProviderDefaultModel, + getProviderEnvVar as getSharedProviderEnvVar, +} from '../shared/providers/registry'; -interface ProviderModelEntry extends Record { - id: string; - name: string; -} - - -interface ProviderBackendMeta { - envVar?: string; - defaultModel?: string; - /** OpenClaw models.providers config (omit for built-in providers like anthropic) */ - providerConfig?: { - baseUrl: string; - api: string; - apiKeyEnv: string; - models?: ProviderModelEntry[]; - headers?: Record; - }; -} - -const REGISTRY: Record = { - anthropic: { - envVar: 'ANTHROPIC_API_KEY', - defaultModel: 'anthropic/claude-opus-4-6', - // anthropic is built-in to OpenClaw's model registry, no provider config needed - }, - openai: { - envVar: 'OPENAI_API_KEY', - defaultModel: 'openai/gpt-5.2', - providerConfig: { - baseUrl: 'https://api.openai.com/v1', - api: 'openai-responses', - apiKeyEnv: 'OPENAI_API_KEY', - }, - }, - google: { - envVar: 'GEMINI_API_KEY', - defaultModel: 'google/gemini-3.1-pro-preview', - // google is built-in to OpenClaw's pi-ai catalog, no providerConfig needed. - // Adding models.providers.google overrides the built-in and can break Gemini. - }, - openrouter: { - envVar: 'OPENROUTER_API_KEY', - defaultModel: 'openrouter/anthropic/claude-opus-4.6', - providerConfig: { - baseUrl: 'https://openrouter.ai/api/v1', - api: 'openai-completions', - apiKeyEnv: 'OPENROUTER_API_KEY', - headers: { - 'HTTP-Referer': 'https://claw-x.com', - 'X-Title': 'ClawX', - }, - }, - }, - ark: { - envVar: 'ARK_API_KEY', - providerConfig: { - baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - api: 'openai-completions', - apiKeyEnv: 'ARK_API_KEY', - }, - }, - moonshot: { - envVar: 'MOONSHOT_API_KEY', - defaultModel: 'moonshot/kimi-k2.5', - providerConfig: { - baseUrl: 'https://api.moonshot.cn/v1', - api: 'openai-completions', - apiKeyEnv: 'MOONSHOT_API_KEY', - models: [ - { - id: 'kimi-k2.5', - name: 'Kimi K2.5', - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 256000, - maxTokens: 8192, - }, - ], - }, - }, - siliconflow: { - envVar: 'SILICONFLOW_API_KEY', - defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3', - providerConfig: { - baseUrl: 'https://api.siliconflow.cn/v1', - api: 'openai-completions', - apiKeyEnv: 'SILICONFLOW_API_KEY', - }, - }, - 'minimax-portal': { - envVar: 'MINIMAX_API_KEY', - defaultModel: 'minimax-portal/MiniMax-M2.5', - providerConfig: { - baseUrl: 'https://api.minimax.io/anthropic', - api: 'anthropic-messages', - apiKeyEnv: 'MINIMAX_API_KEY', - }, - }, - 'minimax-portal-cn': { - envVar: 'MINIMAX_CN_API_KEY', - defaultModel: 'minimax-portal/MiniMax-M2.5', - providerConfig: { - baseUrl: 'https://api.minimaxi.com/anthropic', - api: 'anthropic-messages', - apiKeyEnv: 'MINIMAX_CN_API_KEY', - }, - }, - 'qwen-portal': { - envVar: 'QWEN_API_KEY', - defaultModel: 'qwen-portal/coder-model', - providerConfig: { - baseUrl: 'https://portal.qwen.ai/v1', - api: 'openai-completions', - apiKeyEnv: 'QWEN_API_KEY', - }, - }, - custom: { - envVar: 'CUSTOM_API_KEY', - }, - // Additional providers with env var mappings but no default model +// Additional env-backed providers that are not yet exposed in the UI. +const EXTRA_ENV_ONLY_PROVIDERS: Record = { groq: { envVar: 'GROQ_API_KEY' }, deepgram: { envVar: 'DEEPGRAM_API_KEY' }, cerebras: { envVar: 'CEREBRAS_API_KEY' }, @@ -151,19 +29,19 @@ const REGISTRY: Record = { /** Get the environment variable name for a provider type */ export function getProviderEnvVar(type: string): string | undefined { - return REGISTRY[type]?.envVar; + return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar; } /** Get the default model string for a provider type */ export function getProviderDefaultModel(type: string): string | undefined { - return REGISTRY[type]?.defaultModel; + return getSharedProviderDefaultModel(type); } /** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models, headers) */ export function getProviderConfig( type: string ): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[]; headers?: Record } | undefined { - return REGISTRY[type]?.providerConfig; + return getProviderBackendConfig(type) as ProviderBackendConfig | undefined; } /** @@ -171,7 +49,5 @@ export function getProviderConfig( * Used by GatewayManager to inject API keys as env vars. */ export function getKeyableProviderTypes(): string[] { - return Object.entries(REGISTRY) - .filter(([, meta]) => meta.envVar) - .map(([type]) => type); + return [...getSharedKeyableProviderTypes(), ...Object.keys(EXTRA_ENV_ONLY_PROVIDERS)]; } diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 3b40847..b95758e 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -1,30 +1,28 @@ /** * Provider Storage * Manages provider configurations and API keys. - * Keys are stored in plain text alongside provider configs in a single electron-store. + * This file remains the legacy compatibility layer while the app migrates to + * account-based provider storage and a dedicated secret-store abstraction. */ import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry'; import { getActiveOpenClawProviders } from './openclaw-auth'; - -// Lazy-load electron-store (ESM module) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let providerStore: any = null; - -async function getProviderStore() { - if (!providerStore) { - const Store = (await import('electron-store')).default; - providerStore = new Store({ - name: 'clawx-providers', - defaults: { - providers: {} as Record, - apiKeys: {} as Record, - defaultProvider: null as string | null, - }, - }); - } - return providerStore; -} +import { + deleteProviderAccount, + getProviderAccount, + listProviderAccounts, + providerAccountToConfig, + providerConfigToAccount, + saveProviderAccount, + setDefaultProviderAccount, +} from '../services/providers/provider-store'; +import { ensureProviderStoreMigrated } from '../services/providers/provider-migration'; +import { getClawXProviderStore } from '../services/providers/store-instance'; +import { + deleteProviderSecret, + getProviderSecret, + setProviderSecret, +} from '../services/secrets/secret-store'; /** * Provider configuration @@ -49,10 +47,16 @@ export interface ProviderConfig { */ export async function storeApiKey(providerId: string, apiKey: string): Promise { try { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; keys[providerId] = apiKey; s.set('apiKeys', keys); + await setProviderSecret({ + type: 'api_key', + accountId: providerId, + apiKey, + }); return true; } catch (error) { console.error('Failed to store API key:', error); @@ -65,7 +69,16 @@ export async function storeApiKey(providerId: string, apiKey: string): Promise { try { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const secret = await getProviderSecret(providerId); + if (secret?.type === 'api_key') { + return secret.apiKey; + } + if (secret?.type === 'local') { + return secret.apiKey ?? null; + } + + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; return keys[providerId] || null; } catch (error) { @@ -79,10 +92,12 @@ export async function getApiKey(providerId: string): Promise { */ export async function deleteApiKey(providerId: string): Promise { try { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; delete keys[providerId]; s.set('apiKeys', keys); + await deleteProviderSecret(providerId); return true; } catch (error) { console.error('Failed to delete API key:', error); @@ -94,7 +109,13 @@ export async function deleteApiKey(providerId: string): Promise { * Check if an API key exists for a provider */ export async function hasApiKey(providerId: string): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const secret = await getProviderSecret(providerId); + if (secret?.type === 'api_key') { + return true; + } + + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; return providerId in keys; } @@ -103,7 +124,8 @@ export async function hasApiKey(providerId: string): Promise { * List all provider IDs that have stored keys */ export async function listStoredKeyIds(): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; return Object.keys(keys); } @@ -114,28 +136,47 @@ export async function listStoredKeyIds(): Promise { * Save a provider configuration */ export async function saveProvider(config: ProviderConfig): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; providers[config.id] = config; s.set('providers', providers); + + const defaultProviderId = (s.get('defaultProvider') ?? null) as string | null; + await saveProviderAccount( + providerConfigToAccount(config, { isDefault: defaultProviderId === config.id }), + ); } /** * Get a provider configuration */ export async function getProvider(providerId: string): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; - return providers[providerId] || null; + if (providers[providerId]) { + return providers[providerId]; + } + + const account = await getProviderAccount(providerId); + return account ? providerAccountToConfig(account) : null; } /** * Get all provider configurations */ export async function getAllProviders(): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; - return Object.values(providers); + const legacyProviders = Object.values(providers); + if (legacyProviders.length > 0) { + return legacyProviders; + } + + const accounts = await listProviderAccounts(); + return accounts.map(providerAccountToConfig); } /** @@ -143,18 +184,21 @@ export async function getAllProviders(): Promise { */ export async function deleteProvider(providerId: string): Promise { try { + await ensureProviderStoreMigrated(); // Delete the API key await deleteApiKey(providerId); // Delete the provider config - const s = await getProviderStore(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; delete providers[providerId]; s.set('providers', providers); + await deleteProviderAccount(providerId); // Clear default if this was the default if (s.get('defaultProvider') === providerId) { s.delete('defaultProvider'); + s.delete('defaultProviderAccountId'); } return true; @@ -168,16 +212,20 @@ export async function deleteProvider(providerId: string): Promise { * Set the default provider */ export async function setDefaultProvider(providerId: string): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); s.set('defaultProvider', providerId); + await setDefaultProviderAccount(providerId); } /** * Get the default provider */ export async function getDefaultProvider(): Promise { - const s = await getProviderStore(); - return s.get('defaultProvider') as string | undefined; + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); + return (s.get('defaultProvider') as string | undefined) + ?? (s.get('defaultProviderAccountId') as string | undefined); } /** diff --git a/src/lib/providers.ts b/src/lib/providers.ts index b3fdfa5..2c94306 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -1,8 +1,9 @@ /** * Provider Types & UI Metadata — single source of truth for the frontend. * - * NOTE: When adding a new provider type, also update - * electron/utils/provider-registry.ts (env vars, models, configs). + * NOTE: Backend provider metadata is being refactored toward the new + * account-based registry, but the renderer still keeps a local compatibility + * layer so TypeScript project boundaries remain stable during the migration. */ export const PROVIDER_TYPES = [ @@ -21,6 +22,20 @@ export const PROVIDER_TYPES = [ ] as const; export type ProviderType = (typeof PROVIDER_TYPES)[number]; +export const BUILTIN_PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'ark', + 'moonshot', + 'siliconflow', + 'minimax-portal', + 'minimax-portal-cn', + 'qwen-portal', + 'ollama', +] as const; + export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local'; export interface ProviderConfig { @@ -46,26 +61,16 @@ export interface ProviderTypeInfo { name: string; icon: string; placeholder: string; - /** Model brand name for display (e.g. "Claude", "GPT") */ model?: string; requiresApiKey: boolean; - /** Pre-filled base URL (for proxy/compatible providers like SiliconFlow) */ defaultBaseUrl?: string; - /** Whether the user can edit the base URL in setup */ showBaseUrl?: boolean; - /** Whether to show a Model ID input field (for providers where user picks the model) */ showModelId?: boolean; - /** Whether the Model ID input should only be shown in developer mode */ showModelIdInDevModeOnly?: boolean; - /** Default / example model ID placeholder */ modelIdPlaceholder?: string; - /** Default model ID to pre-fill */ defaultModelId?: string; - /** Whether this provider uses OAuth device flow instead of an API key */ isOAuth?: boolean; - /** Whether this provider also accepts a direct API key (in addition to OAuth) */ supportsApiKey?: boolean; - /** URL where users can apply for the API Key */ apiKeyUrl?: string; }