import { PROVIDER_DEFINITIONS, getProviderDefinition, } from '../../shared/providers/registry'; import type { ProviderAccount, ProviderConfig, ProviderDefinition, ProviderType, } from '../../shared/providers/types'; import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types'; import { ensureProviderStoreMigrated } from './provider-migration'; import { deleteProviderAccount, getDefaultProviderAccountId, getProviderAccount, listProviderAccounts, providerAccountToConfig, providerConfigToAccount, saveProviderAccount, setDefaultProviderAccount, } from './provider-store'; import { deleteApiKey, deleteProvider, getApiKey, hasApiKey, setDefaultProvider, storeApiKey, } from '../../utils/secure-storage'; import { getActiveOpenClawProviders, getOpenClawProvidersConfig, getProviderApiKeyFromOpenClaw, } from '../../utils/openclaw-auth'; import { filterActiveProviderKeysForUi, getAliasSourceTypes, OPENAI_CODEX_RUNTIME_PROVIDER_KEY, resolveOpenClawProviderKey, } from '../../utils/provider-keys'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; function maskApiKey(apiKey: string | null): string | null { if (!apiKey) return null; if (apiKey.length > 12) { return `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`; } return '*'.repeat(apiKey.length); } const legacyProviderApiWarned = new Set(); function logLegacyProviderApiUsage(method: string, replacement: string): void { if (legacyProviderApiWarned.has(method)) { return; } legacyProviderApiWarned.add(method); logger.warn( `[provider-migration] Legacy provider API "${method}" is deprecated. Migrate to "${replacement}".`, ); } function inferProviderVendorIdFromOpenClawEntry( key: string, entry: Record, ): ProviderType | 'custom' { if (key === 'minimax-portal') { const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl.toLowerCase() : ''; if (baseUrl.includes('api.minimaxi.com')) { return 'minimax-portal-cn'; } } return ((BUILTIN_PROVIDER_TYPES as readonly string[]).includes(key) ? key : 'custom') as ProviderType | 'custom'; } export class ProviderService { async listVendors(): Promise { return PROVIDER_DEFINITIONS; } async listAccounts(): Promise { await ensureProviderStoreMigrated(); // ── openclaw.json is the ONLY source of truth ── // The provider list is derived entirely from openclaw.json. // The electron-store is only used as a metadata cache (label, authMode, etc.). const { providers: openClawProviders, defaultModel } = await getOpenClawProvidersConfig(); const activeProviders = await getActiveOpenClawProviders(); if (activeProviders.size === 0) { return []; } // Read store accounts as a lookup cache (NOT as the source of what to display). const allStoreAccounts = await listProviderAccounts(); // Index store accounts by their openclaw runtime key for fast lookup. const storeByKey = new Map(); for (const account of allStoreAccounts) { const ock = resolveOpenClawProviderKey(account); const group = storeByKey.get(ock) ?? []; group.push(account); storeByKey.set(ock, group); } const result: ProviderAccount[] = []; const processedKeys = new Set(); let hasConfiguredOpenAiApiKey = false; if (activeProviders.has('openai')) { for (const account of storeByKey.get('openai') ?? []) { if (account.authMode === 'oauth_browser') { continue; } const apiKey = await getApiKey(account.id); const openClawKey = await getProviderApiKeyFromOpenClaw('openai'); if (apiKey || openClawKey) { hasConfiguredOpenAiApiKey = true; break; } } } const activeKeysForUi = filterActiveProviderKeysForUi(activeProviders, { hasConfiguredOpenAiApiKey, }); // For each active provider in openclaw.json, produce exactly ONE account. for (const key of activeKeysForUi) { if (processedKeys.has(key)) continue; processedKeys.add(key); const storeGroup = storeByKey.get(key) ?? []; if (storeGroup.length > 0) { // Pick the best store account for this key: // 1. Prefer alias variants (e.g. minimax-portal-cn over minimax-portal) // 2. Among equal variants, prefer the most recently updated const aliasAccounts = storeGroup.filter((a) => a.vendorId !== key); const candidates = aliasAccounts.length > 0 ? aliasAccounts : storeGroup; candidates.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); result.push(candidates[0]); // Clean up orphaned duplicates from the store. const kept = candidates[0]; for (const account of storeGroup) { if (account.id !== kept.id) { logger.info( `[provider-sync] Removing orphaned account "${account.id}" for key "${key}" (keeping "${kept.id}")`, ); await deleteProviderAccount(account.id); } } } else { // No store account for this key — create a seed from openclaw.json. const entry = openClawProviders[key]; if (entry) { const seeded = ProviderService.buildAccountsFromOpenClawEntries( { [key]: entry }, new Set(), new Set(), defaultModel, ); for (const account of seeded) { await saveProviderAccount(account); result.push(account); logger.info(`[provider-sync] Seeded provider account "${account.id}" from openclaw.json`); } } } } if (activeProviders.has(OPENAI_CODEX_RUNTIME_PROVIDER_KEY)) { const openaiStoreAccounts = storeByKey.get('openai') ?? []; for (const account of openaiStoreAccounts) { if (account.authMode !== 'api_key' && account.authMode !== undefined) { continue; } const apiKey = await getApiKey(account.id); const openClawKey = await getProviderApiKeyFromOpenClaw('openai'); if (!apiKey && !openClawKey) { logger.info( `[provider-sync] Removing unconfigured OpenAI API key account "${account.id}" (OAuth uses ${OPENAI_CODEX_RUNTIME_PROVIDER_KEY})`, ); await deleteProviderAccount(account.id); } } } return result; } /** * Build ProviderAccount objects from OpenClaw config entries, skipping any * whose id or vendorId is already represented by an existing account. */ static buildAccountsFromOpenClawEntries( providers: Record>, existingIds: Set, existingVendorIds: Set, defaultModel: string | undefined, ): ProviderAccount[] { const defaultModelProvider = defaultModel?.includes('/') ? defaultModel.split('/')[0] : undefined; const now = new Date().toISOString(); const built: ProviderAccount[] = []; for (const [key, entry] of Object.entries(providers)) { if (existingIds.has(key)) continue; const vendorId = inferProviderVendorIdFromOpenClawEntry(key, entry); const definition = getProviderDefinition(vendorId === 'custom' ? key : vendorId); // Skip if an account with this vendorId already exists (e.g. user already // created "openrouter-uuid" via UI — no need to import bare "openrouter"). if (existingVendorIds.has(vendorId)) continue; // Skip if an alias source type already exists. // e.g. openclaw.json has "minimax-portal" but account vendorId is "minimax-portal-cn" const aliasSources = getAliasSourceTypes(key); if (aliasSources.some((source) => existingVendorIds.has(source))) { continue; } const baseUrl = typeof entry.baseUrl === 'string' ? entry.baseUrl : definition?.providerConfig?.baseUrl; // Infer model from the default model if it belongs to this provider let model: string | undefined; if (defaultModelProvider === key && defaultModel) { model = defaultModel; } else if (definition?.defaultModelId) { model = definition.defaultModelId; } const account: ProviderAccount = { id: key, vendorId: (vendorId as ProviderAccount['vendorId'] as ProviderType), label: definition?.name ?? key.charAt(0).toUpperCase() + key.slice(1), authMode: definition?.defaultAuthMode ?? 'api_key', baseUrl, apiProtocol: definition?.providerConfig?.api, headers: (entry.headers && typeof entry.headers === 'object' ? (entry.headers as Record) : undefined), model, enabled: true, isDefault: false, createdAt: now, updatedAt: now, }; built.push(account); } return built; } async getAccount(accountId: string): Promise { await ensureProviderStoreMigrated(); return getProviderAccount(accountId); } async getDefaultAccountId(): Promise { await ensureProviderStoreMigrated(); return getDefaultProviderAccountId(); } async createAccount(account: ProviderAccount, apiKey?: string): Promise { await ensureProviderStoreMigrated(); // Only save to providerAccounts store — do NOT call saveProvider() which // writes to the legacy `providers` store and causes phantom/duplicate issues. await saveProviderAccount(account); if (apiKey !== undefined && apiKey.trim()) { await storeApiKey(account.id, apiKey.trim()); } return (await getProviderAccount(account.id)) ?? account; } async updateAccount( accountId: string, patch: Partial, apiKey?: string, ): Promise { await ensureProviderStoreMigrated(); const existing = await getProviderAccount(accountId); if (!existing) { throw new Error('Provider account not found'); } const nextAccount: ProviderAccount = { ...existing, ...patch, id: accountId, updatedAt: patch.updatedAt ?? new Date().toISOString(), }; // Only save to providerAccounts store — skip legacy saveProvider(). await saveProviderAccount(nextAccount); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { await storeApiKey(accountId, trimmedKey); } else { await deleteApiKey(accountId); } } return (await getProviderAccount(accountId)) ?? nextAccount; } async deleteAccount(accountId: string): Promise { await ensureProviderStoreMigrated(); return deleteProvider(accountId); } // ── Internal silent variants ───────────────────────────────────── // These mirror the legacy public API but never emit deprecation // warnings, so internal callers (HTTP routes, IPC handlers, the new // /api/provider-accounts surface) can reuse the same logic without // contributing to the migration noise. Public legacy methods below // delegate here after logging exactly once per process. /** Internal: list providers in the legacy ProviderConfig shape. */ async _listProvidersFromAccountsInternal(): Promise { const accounts = await this.listAccounts(); return accounts.map(providerAccountToConfig); } /** Internal: list providers with hasKey/keyMasked metadata. */ async _listProvidersWithKeyInfoInternal(): Promise { const providers = await this._listProvidersFromAccountsInternal(); const results: ProviderWithKeyInfo[] = []; for (const provider of providers) { const apiKey = await getApiKey(provider.id); results.push({ ...provider, hasKey: !!apiKey, keyMasked: maskApiKey(apiKey), }); } return results; } /** Internal: resolve a single provider in the legacy ProviderConfig shape. */ async _getProviderInternal(providerId: string): Promise { await ensureProviderStoreMigrated(); const account = await getProviderAccount(providerId); return account ? providerAccountToConfig(account) : null; } /** Internal: upsert a legacy provider config (creates or updates the account). */ async _saveProviderInternal(config: ProviderConfig): Promise { await ensureProviderStoreMigrated(); const account = providerConfigToAccount(config); const existing = await getProviderAccount(config.id); if (existing) { await this.updateAccount(config.id, account); return; } await this.createAccount(account); } /** Internal: delete a provider account by id. */ async _deleteProviderInternal(providerId: string): Promise { await ensureProviderStoreMigrated(); await this.deleteAccount(providerId); return true; } /** Internal: set default account without warning. */ async _setDefaultProviderInternal(providerId: string): Promise { await this.setDefaultAccount(providerId); } /** Internal: read default account id without warning. */ async _getDefaultProviderInternal(): Promise { return this.getDefaultAccountId(); } /** Internal: store an account's api key without warning. */ async _setProviderApiKeyInternal(providerId: string, apiKey: string): Promise { return storeApiKey(providerId, apiKey); } /** Internal: read an account's api key without warning. */ async _getProviderApiKeyInternal(providerId: string): Promise { return getApiKey(providerId); } /** Internal: delete an account's api key without warning. */ async _deleteProviderApiKeyInternal(providerId: string): Promise { return deleteApiKey(providerId); } /** Internal: check if an account has a stored api key. */ async _hasProviderApiKeyInternal(providerId: string): Promise { return hasApiKey(providerId); } // ── New clean account-based public API ─────────────────────────── // These never log deprecation warnings — they operate purely in // the account namespace and are the preferred surface for the // /api/provider-accounts/* HTTP routes and modern renderer code. /** Return per-account API key status for the new account API surface. */ async listAccountsKeyInfo(): Promise> { const accounts = await this.listAccounts(); const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = []; for (const account of accounts) { const runtimeProviderKey = resolveOpenClawProviderKey(account); const apiKey = (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) ?? (await getApiKey(account.id)) ?? (runtimeProviderKey !== account.id ? await getApiKey(runtimeProviderKey) : null); results.push({ accountId: account.id, hasKey: !!apiKey, keyMasked: maskApiKey(apiKey), }); } return results; } /** Read an account's API key (clean alternative to getLegacyProviderApiKey). */ async getAccountApiKey(accountId: string): Promise { return this._getProviderApiKeyInternal(accountId); } /** Check whether an account has an API key stored. */ async hasAccountApiKey(accountId: string): Promise { const account = await this.getAccount(accountId); const runtimeProviderKey = account ? resolveOpenClawProviderKey(account) : accountId; if (await getProviderApiKeyFromOpenClaw(runtimeProviderKey)) { return true; } if (runtimeProviderKey !== accountId && (await hasApiKey(runtimeProviderKey))) { return true; } return this._hasProviderApiKeyInternal(accountId); } // ── Legacy public API (logs deprecation warning once per method) ─ // These exist solely for backward compatibility with external clients // (older Gateway code, third-party tooling, in-flight tests). Internal // ClawX callers should use the internal/clean methods above. /** * @deprecated Use listAccounts() and map account data in callers. */ async listLegacyProviders(): Promise { logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts'); return this._listProvidersFromAccountsInternal(); } /** * @deprecated Use listAccountsKeyInfo() + the account snapshot API. */ async listLegacyProvidersWithKeyInfo(): Promise { logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccountsKeyInfo'); return this._listProvidersWithKeyInfoInternal(); } /** * @deprecated Use getAccount(accountId). */ async getLegacyProvider(providerId: string): Promise { logLegacyProviderApiUsage('getLegacyProvider', 'getAccount'); return this._getProviderInternal(providerId); } /** * @deprecated Use createAccount()/updateAccount(). */ async saveLegacyProvider(config: ProviderConfig): Promise { logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount'); return this._saveProviderInternal(config); } /** * @deprecated Use deleteAccount(accountId). */ async deleteLegacyProvider(providerId: string): Promise { logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount'); return this._deleteProviderInternal(providerId); } /** * @deprecated Use setDefaultAccount(accountId). */ async setDefaultLegacyProvider(providerId: string): Promise { logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount'); return this._setDefaultProviderInternal(providerId); } /** * @deprecated Use getDefaultAccountId(). */ async getDefaultLegacyProvider(): Promise { logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId'); return this._getDefaultProviderInternal(); } /** * @deprecated Use secret-store APIs by accountId. */ async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise { logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)'); return this._setProviderApiKeyInternal(providerId, apiKey); } /** * @deprecated Use getAccountApiKey(accountId). */ async getLegacyProviderApiKey(providerId: string): Promise { logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getAccountApiKey'); return this._getProviderApiKeyInternal(providerId); } /** * @deprecated Use secret-store APIs by accountId. */ async deleteLegacyProviderApiKey(providerId: string): Promise { logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)'); return this._deleteProviderApiKeyInternal(providerId); } /** * @deprecated Use hasAccountApiKey(accountId). */ async hasLegacyProviderApiKey(providerId: string): Promise { logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'hasAccountApiKey'); return this._hasProviderApiKeyInternal(providerId); } async setDefaultAccount(accountId: string): Promise { await ensureProviderStoreMigrated(); await setDefaultProviderAccount(accountId); await setDefaultProvider(accountId); } getVendorDefinition(vendorId: string): ProviderDefinition | undefined { return getProviderDefinition(vendorId); } } const providerService = new ProviderService(); export function getProviderService(): ProviderService { return providerService; }