Refactor clawx (#344)

Co-authored-by: ashione <skyzlxuan@gmail.com>
This commit is contained in:
paisley
2026-03-09 13:10:42 +08:00
committed by GitHub
Unverified
parent 3d804a9f5e
commit 2c5c82bb74
75 changed files with 7640 additions and 3106 deletions

View File

@@ -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<void> {
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<string, ProviderConfig>;
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);
}

View File

@@ -0,0 +1,460 @@
import type { GatewayManager } from '../../gateway/manager';
import { getProviderAccount, listProviderAccounts } from './provider-store';
import { getProviderSecret } from '../secrets/secret-store';
import type { ProviderConfig } from '../../utils/secure-storage';
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
import {
removeProviderFromOpenClaw,
saveOAuthTokenToOpenClaw,
saveProviderKeyToOpenClaw,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
} from '../../utils/openclaw-auth';
import { logger } from '../../utils/logger';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
export function getOpenClawProviderKey(type: string, providerId: string): string {
if (type === 'custom' || type === 'ollama') {
const suffix = providerId.replace(/-/g, '').slice(0, 8);
return `${type}-${suffix}`;
}
if (type === 'minimax-portal-cn') {
return 'minimax-portal';
}
return type;
}
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
const account = await getProviderAccount(config.id);
if (config.type === 'google' && account?.authMode === 'oauth_browser') {
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
}
return getOpenClawProviderKey(config.type, config.id);
}
async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise<boolean> {
const account = await getProviderAccount(config.id);
if (config.type !== 'google' || account?.authMode !== 'oauth_browser') {
return false;
}
const secret = await getProviderSecret(config.id);
return secret?.type === 'oauth';
}
export function getProviderModelRef(config: ProviderConfig): string | undefined {
const providerKey = getOpenClawProviderKey(config.type, config.id);
if (config.model) {
return config.model.startsWith(`${providerKey}/`)
? config.model
: `${providerKey}/${config.model}`;
}
const defaultModel = getProviderDefaultModel(config.type);
if (!defaultModel) {
return undefined;
}
return defaultModel.startsWith(`${providerKey}/`)
? defaultModel
: `${providerKey}/${defaultModel}`;
}
export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
const allProviders = await getAllProviders();
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
const seen = new Set<string>();
const results: string[] = [];
const providerKey = getOpenClawProviderKey(config.type, config.id);
for (const fallbackModel of config.fallbackModels ?? []) {
const normalizedModel = fallbackModel.trim();
if (!normalizedModel) continue;
const modelRef = normalizedModel.startsWith(`${providerKey}/`)
? normalizedModel
: `${providerKey}/${normalizedModel}`;
if (seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
for (const fallbackId of config.fallbackProviderIds ?? []) {
if (!fallbackId || fallbackId === config.id) continue;
const fallbackProvider = providerMap.get(fallbackId);
if (!fallbackProvider) continue;
const modelRef = getProviderModelRef(fallbackProvider);
if (!modelRef || seen.has(modelRef)) continue;
seen.add(modelRef);
results.push(modelRef);
}
return results;
}
function scheduleGatewayRestart(
gatewayManager: GatewayManager | undefined,
message: string,
options?: { delayMs?: number; onlyIfRunning?: boolean },
): void {
if (!gatewayManager) {
return;
}
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
return;
}
logger.info(message);
gatewayManager.debouncedRestart(options?.delayMs);
}
export async function syncProviderApiKeyToRuntime(
providerType: string,
providerId: string,
apiKey: string,
): Promise<void> {
const ock = getOpenClawProviderKey(providerType, providerId);
await saveProviderKeyToOpenClaw(ock, apiKey);
}
export async function syncAllProviderAuthToRuntime(): Promise<void> {
const accounts = await listProviderAccounts();
for (const account of accounts) {
const runtimeProviderKey = await resolveRuntimeProviderKey({
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,
});
const secret = await getProviderSecret(account.id);
if (!secret) {
continue;
}
if (secret.type === 'api_key') {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
continue;
}
if (secret.type === 'oauth') {
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
}
}
export async function syncSavedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const ock = await resolveRuntimeProviderKey(config);
const secret = await getProviderSecret(config.id);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(ock, trimmedKey);
}
} else if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
} else if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(ock, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
} else if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
}
const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
if (!api) {
return;
}
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
headers: meta?.headers,
});
if (config.type === 'custom' || config.type === 'ollama') {
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
}
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after saving provider "${ock}" config`,
);
}
export async function syncUpdatedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
gatewayManager?: GatewayManager,
): Promise<void> {
const ock = await resolveRuntimeProviderKey(config);
const fallbackModels = await getProviderFallbackModelRefs(config);
const meta = getProviderConfig(config.type);
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
const secret = await getProviderSecret(config.id);
if (!api) {
return;
}
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await saveProviderKeyToOpenClaw(ock, trimmedKey);
}
} else if (secret?.type === 'api_key') {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
} else if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(ock, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
} else if (secret?.type === 'local' && secret.apiKey) {
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
}
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
headers: meta?.headers,
});
if (config.type === 'custom' || config.type === 'ollama') {
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: resolvedKey,
});
}
}
const defaultProviderId = await getDefaultProvider();
if (defaultProviderId === config.id) {
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
if (config.type !== 'custom' && config.type !== 'ollama') {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
} else {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: config.baseUrl,
api: 'openai-completions',
}, fallbackModels);
}
}
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after updating provider "${ock}" config`,
);
}
export async function syncDeletedProviderToRuntime(
provider: ProviderConfig | null,
providerId: string,
gatewayManager?: GatewayManager,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after deleting provider "${ock}"`,
);
}
export async function syncDeletedProviderApiKeyToRuntime(
provider: ProviderConfig | null,
providerId: string,
runtimeProviderKey?: string,
): Promise<void> {
if (!provider?.type) {
return;
}
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
await removeProviderFromOpenClaw(ock);
}
export async function syncDefaultProviderToRuntime(
providerId: string,
gatewayManager?: GatewayManager,
): Promise<void> {
const provider = await getProvider(providerId);
if (!provider) {
return;
}
const ock = await resolveRuntimeProviderKey(provider);
const providerKey = await getApiKey(providerId);
const fallbackModels = await getProviderFallbackModelRefs(provider);
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider);
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider;
if (!isOAuthProvider) {
const modelOverride = provider.model
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
: undefined;
if (provider.type === 'custom' || provider.type === 'ollama') {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
}, fallbackModels);
} else {
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
}
if (providerKey) {
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
if (isGoogleOAuthProvider) {
const secret = await getProviderSecret(provider.id);
if (secret?.type === 'oauth') {
await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, {
access: secret.accessToken,
refresh: secret.refreshToken,
expires: secret.expiresAt,
email: secret.email,
projectId: secret.subject,
});
}
const modelOverride = provider.model
? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`)
? provider.model
: `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`)
: GOOGLE_OAUTH_DEFAULT_MODEL_REF;
await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`);
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`,
);
return;
}
const defaultBaseUrl = provider.type === 'minimax-portal'
? 'https://api.minimax.io/anthropic'
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
const api: 'anthropic-messages' | 'openai-completions' =
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'anthropic-messages'
: 'openai-completions';
let baseUrl = provider.baseUrl || defaultBaseUrl;
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
? 'minimax-portal'
: provider.type;
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
}, fallbackModels);
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
try {
const defaultModelId = provider.model?.split('/').pop();
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
});
} catch (err) {
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
}
}
if (
(provider.type === 'custom' || provider.type === 'ollama') &&
providerKey &&
provider.baseUrl
) {
const modelId = provider.model;
await updateAgentModelProvider(ock, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
apiKey: providerKey,
});
}
scheduleGatewayRestart(
gatewayManager,
`Scheduling Gateway restart after provider switch to "${ock}"`,
{ onlyIfRunning: true },
);
}

View File

@@ -0,0 +1,168 @@
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,
providerAccountToConfig,
providerConfigToAccount,
saveProviderAccount,
setDefaultProviderAccount,
} from './provider-store';
import {
deleteApiKey,
deleteProvider,
getAllProviders,
getAllProvidersWithKeyInfo,
getApiKey,
getDefaultProvider,
getProvider,
hasApiKey,
saveProvider,
setDefaultProvider,
storeApiKey,
} from '../../utils/secure-storage';
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
export class ProviderService {
async listVendors(): Promise<ProviderDefinition[]> {
return PROVIDER_DEFINITIONS;
}
async listAccounts(): Promise<ProviderAccount[]> {
await ensureProviderStoreMigrated();
return listProviderAccounts();
}
async getAccount(accountId: string): Promise<ProviderAccount | null> {
await ensureProviderStoreMigrated();
return getProviderAccount(accountId);
}
async getDefaultAccountId(): Promise<string | undefined> {
await ensureProviderStoreMigrated();
return (await getDefaultProvider()) ?? getDefaultProviderAccountId();
}
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
await saveProvider(providerAccountToConfig(account));
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<ProviderAccount>,
apiKey?: string,
): Promise<ProviderAccount> {
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(),
};
await saveProvider(providerAccountToConfig(nextAccount));
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<boolean> {
await ensureProviderStoreMigrated();
return deleteProvider(accountId);
}
async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise<ProviderAccount> {
await ensureProviderStoreMigrated();
const account = providerConfigToAccount(config, options);
await saveProviderAccount(account);
return account;
}
async listLegacyProviders(): Promise<ProviderConfig[]> {
return getAllProviders();
}
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
return getAllProvidersWithKeyInfo();
}
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
return getProvider(providerId);
}
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
await saveProvider(config);
}
async deleteLegacyProvider(providerId: string): Promise<boolean> {
return deleteProvider(providerId);
}
async setDefaultLegacyProvider(providerId: string): Promise<void> {
await setDefaultProvider(providerId);
}
async getDefaultLegacyProvider(): Promise<string | undefined> {
return getDefaultProvider();
}
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
return storeApiKey(providerId, apiKey);
}
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
return getApiKey(providerId);
}
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
return deleteApiKey(providerId);
}
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
return hasApiKey(providerId);
}
async setDefaultAccount(accountId: string): Promise<void> {
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;
}

View File

@@ -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<ProviderAccount[]> {
const store = await getClawXProviderStore();
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
return Object.values(accounts ?? {});
}
export async function getProviderAccount(accountId: string): Promise<ProviderAccount | null> {
const store = await getClawXProviderStore();
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
return accounts?.[accountId] ?? null;
}
export async function saveProviderAccount(account: ProviderAccount): Promise<void> {
const store = await getClawXProviderStore();
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
accounts[account.id] = account;
store.set('providerAccounts', accounts);
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
}
export async function deleteProviderAccount(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
delete accounts[accountId];
store.set('providerAccounts', accounts);
if (store.get('defaultProviderAccountId') === accountId) {
store.delete('defaultProviderAccountId');
}
}
export async function setDefaultProviderAccount(accountId: string): Promise<void> {
const store = await getClawXProviderStore();
store.set('defaultProviderAccountId', accountId);
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
for (const account of Object.values(accounts)) {
account.isDefault = account.id === accountId;
}
store.set('providerAccounts', accounts);
}
export async function getDefaultProviderAccountId(): Promise<string | undefined> {
const store = await getClawXProviderStore();
return store.get('defaultProviderAccountId') as string | undefined;
}

View File

@@ -0,0 +1,238 @@
import { proxyAwareFetch } from '../../utils/proxy-fetch';
type ValidationProfile =
| 'openai-compatible'
| 'google-query-key'
| 'anthropic-header'
| 'openrouter'
| 'none';
function logValidationStatus(provider: string, status: number): void {
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
}
function maskSecret(secret: string): string {
if (!secret) return '';
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
}
function sanitizeValidationUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const key = url.searchParams.get('key');
if (key) url.searchParams.set('key', maskSecret(key));
return url.toString();
} catch {
return rawUrl;
}
}
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
if (next.Authorization?.startsWith('Bearer ')) {
const token = next.Authorization.slice('Bearer '.length);
next.Authorization = `Bearer ${maskSecret(token)}`;
}
if (next['x-api-key']) {
next['x-api-key'] = maskSecret(next['x-api-key']);
}
return next;
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}
function logValidationRequest(
provider: string,
method: string,
url: string,
headers: Record<string, string>,
): void {
console.log(
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
);
}
function getValidationProfile(providerType: string): ValidationProfile {
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
case 'google':
return 'google-query-key';
case 'openrouter':
return 'openrouter';
case 'ollama':
return 'none';
default:
return 'openai-compatible';
}
}
async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await proxyAwareFetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
function classifyAuthResponse(
status: number,
data: unknown,
): { valid: boolean; error?: string } {
if (status >= 200 && status < 300) return { valid: true };
if (status === 429) return { valid: true };
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
const obj = data as { error?: { message?: string }; message?: string } | null;
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
return { valid: false, error: msg };
}
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const headers = { Authorization: `Bearer ${apiKey}` };
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
if (modelsResult.error?.includes('API error: 404')) {
console.log(
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`,
);
const base = normalizeBaseUrl(trimmedBaseUrl);
const chatUrl = `${base}/chat/completions`;
return await performChatCompletionsProbe(providerType, chatUrl, headers);
}
return modelsResult;
}
async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>,
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await proxyAwareFetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function validateGoogleQueryKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
}
async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string,
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
return await performProviderValidationRequest(providerType, url, headers);
}
async function validateOpenRouterKey(
providerType: string,
apiKey: string,
): Promise<{ valid: boolean; error?: string }> {
const url = 'https://openrouter.ai/api/v1/auth/key';
const headers = { Authorization: `Bearer ${apiKey}` };
return await performProviderValidationRequest(providerType, url, headers);
}
export async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string },
): Promise<{ valid: boolean; error?: string }> {
const profile = getValidationProfile(providerType);
if (profile === 'none') {
return { valid: true };
}
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}
try {
switch (profile) {
case 'openai-compatible':
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
case 'openrouter':
return await validateOpenRouterKey(providerType, trimmedKey);
default:
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { valid: false, error: errorMessage };
}
}

View File

@@ -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<string, unknown>,
providerAccounts: {} as Record<string, unknown>,
apiKeys: {} as Record<string, string>,
providerSecrets: {} as Record<string, unknown>,
defaultProvider: null as string | null,
defaultProviderAccountId: null as string | null,
},
});
}
return providerStore;
}

View File

@@ -0,0 +1,82 @@
import type { ProviderSecret } from '../../shared/providers/types';
import { getClawXProviderStore } from '../providers/store-instance';
export interface SecretStore {
get(accountId: string): Promise<ProviderSecret | null>;
set(secret: ProviderSecret): Promise<void>;
delete(accountId: string): Promise<void>;
}
export class ElectronStoreSecretStore implements SecretStore {
async get(accountId: string): Promise<ProviderSecret | null> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
const secret = secrets[accountId];
if (secret) {
return secret;
}
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
const apiKey = apiKeys[accountId];
if (!apiKey) {
return null;
}
return {
type: 'api_key',
accountId,
apiKey,
};
}
async set(secret: ProviderSecret): Promise<void> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
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<string, string>;
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<void> {
const store = await getClawXProviderStore();
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
delete secrets[accountId];
store.set('providerSecrets', secrets);
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
delete apiKeys[accountId];
store.set('apiKeys', apiKeys);
}
}
const secretStore = new ElectronStoreSecretStore();
export function getSecretStore(): SecretStore {
return secretStore;
}
export async function getProviderSecret(accountId: string): Promise<ProviderSecret | null> {
return getSecretStore().get(accountId);
}
export async function setProviderSecret(secret: ProviderSecret): Promise<void> {
await getSecretStore().set(secret);
}
export async function deleteProviderSecret(accountId: string): Promise<void> {
await getSecretStore().delete(accountId);
}