Makelore 2.0 initial clean snapshot
This commit is contained in:
47
electron/services/providers/provider-migration.ts
Normal file
47
electron/services/providers/provider-migration.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ProviderConfig } from '../../shared/providers/types';
|
||||
import {
|
||||
getDefaultProviderAccountId,
|
||||
providerConfigToAccount,
|
||||
saveProviderAccount,
|
||||
} from './provider-store';
|
||||
import { getNianCodeProviderStore } from './store-instance';
|
||||
|
||||
const PROVIDER_STORE_SCHEMA_VERSION = 2;
|
||||
|
||||
export async function ensureProviderStoreMigrated(): Promise<void> {
|
||||
const store = await getNianCodeProviderStore();
|
||||
const schemaVersion = Number(store.get('schemaVersion') ?? 0);
|
||||
|
||||
if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
// v0 → v1: migrate legacy `providers` entries to `providerAccounts`.
|
||||
if (schemaVersion < 1) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// v1 → v2: clear the legacy `providers` store.
|
||||
// The old `saveProvider()` was duplicating entries into this store, causing
|
||||
// phantom and duplicate accounts when the migration above re-runs.
|
||||
// Now that createAccount/updateAccount no longer write to `providers`,
|
||||
// we clear it to prevent stale entries from causing issues.
|
||||
if (schemaVersion < 2) {
|
||||
store.set('providers', {});
|
||||
}
|
||||
|
||||
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
|
||||
}
|
||||
381
electron/services/providers/provider-service.ts
Normal file
381
electron/services/providers/provider-service.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
PROVIDER_DEFINITIONS,
|
||||
getProviderDefinition,
|
||||
} from '../../shared/providers/registry';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderConfig,
|
||||
ProviderDefinition,
|
||||
} from '../../shared/providers/types';
|
||||
import type { UserSyncProviderAccount } from '../../../shared/user-sync';
|
||||
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 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<string>();
|
||||
|
||||
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 normalizeSyncedMetadata(metadata: Record<string, unknown>): ProviderAccount['metadata'] {
|
||||
const result: ProviderAccount['metadata'] = {};
|
||||
if (typeof metadata.region === 'string' && metadata.region.trim()) {
|
||||
result.region = metadata.region.trim();
|
||||
}
|
||||
if (typeof metadata.email === 'string' && metadata.email.trim()) {
|
||||
result.email = metadata.email.trim();
|
||||
}
|
||||
if (typeof metadata.resourceUrl === 'string' && metadata.resourceUrl.trim()) {
|
||||
result.resourceUrl = metadata.resourceUrl.trim();
|
||||
}
|
||||
if (Array.isArray(metadata.customModels)) {
|
||||
const customModels = metadata.customModels
|
||||
.filter((model): model is string => typeof model === 'string' && Boolean(model.trim()))
|
||||
.map((model) => model.trim());
|
||||
if (customModels.length > 0) {
|
||||
result.customModels = customModels;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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 getDefaultProviderAccountId();
|
||||
}
|
||||
|
||||
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
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 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 upsertSyncedAccountMetadata(account: UserSyncProviderAccount): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const existing = await getProviderAccount(account.id);
|
||||
const now = new Date().toISOString();
|
||||
const nextAccount: ProviderAccount = {
|
||||
id: account.id,
|
||||
vendorId: account.vendorId as ProviderAccount['vendorId'],
|
||||
label: account.label,
|
||||
authMode: account.authMode as ProviderAccount['authMode'],
|
||||
baseUrl: account.baseUrl,
|
||||
apiProtocol: account.apiProtocol as ProviderAccount['apiProtocol'] | undefined,
|
||||
headers: existing?.headers,
|
||||
model: account.model,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackAccountIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
isDefault: account.isDefault,
|
||||
metadata: normalizeSyncedMetadata(account.metadata ?? {}),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: account.updatedAt ?? now,
|
||||
};
|
||||
|
||||
await saveProviderAccount(nextAccount);
|
||||
if (nextAccount.isDefault) {
|
||||
await this.setDefaultAccount(nextAccount.id);
|
||||
}
|
||||
return (await getProviderAccount(nextAccount.id)) ?? nextAccount;
|
||||
}
|
||||
|
||||
async deleteAccount(accountId: string): Promise<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return deleteProvider(accountId);
|
||||
}
|
||||
|
||||
/** Internal: list providers in the legacy ProviderConfig shape. */
|
||||
async _listProvidersFromAccountsInternal(): Promise<ProviderConfig[]> {
|
||||
const accounts = await this.listAccounts();
|
||||
return accounts.map(providerAccountToConfig);
|
||||
}
|
||||
|
||||
/** Internal: list providers with hasKey/keyMasked metadata. */
|
||||
async _listProvidersWithKeyInfoInternal(): Promise<ProviderWithKeyInfo[]> {
|
||||
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<ProviderConfig | null> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const account = await getProviderAccount(providerId);
|
||||
return account ? providerAccountToConfig(account) : null;
|
||||
}
|
||||
|
||||
/** Internal: upsert a legacy provider config. */
|
||||
async _saveProviderInternal(config: ProviderConfig): Promise<void> {
|
||||
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<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await deleteProviderAccount(providerId);
|
||||
await deleteApiKey(providerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Internal: set default account without warning. */
|
||||
async _setDefaultProviderInternal(providerId: string): Promise<void> {
|
||||
await this.setDefaultAccount(providerId);
|
||||
}
|
||||
|
||||
/** Internal: read default account id without warning. */
|
||||
async _getDefaultProviderInternal(): Promise<string | undefined> {
|
||||
return this.getDefaultAccountId();
|
||||
}
|
||||
|
||||
/** Internal: store an account's api key without warning. */
|
||||
async _setProviderApiKeyInternal(providerId: string, apiKey: string): Promise<boolean> {
|
||||
return storeApiKey(providerId, apiKey);
|
||||
}
|
||||
|
||||
/** Internal: read an account's api key without warning. */
|
||||
async _getProviderApiKeyInternal(providerId: string): Promise<string | null> {
|
||||
return getApiKey(providerId);
|
||||
}
|
||||
|
||||
/** Internal: delete an account's api key without warning. */
|
||||
async _deleteProviderApiKeyInternal(providerId: string): Promise<boolean> {
|
||||
return deleteApiKey(providerId);
|
||||
}
|
||||
|
||||
/** Internal: check if an account has a stored api key. */
|
||||
async _hasProviderApiKeyInternal(providerId: string): Promise<boolean> {
|
||||
return hasApiKey(providerId);
|
||||
}
|
||||
|
||||
/** Return per-account API key status for the account API surface. */
|
||||
async listAccountsKeyInfo(): Promise<Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }>> {
|
||||
const accounts = await this.listAccounts();
|
||||
const results: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
|
||||
for (const account of accounts) {
|
||||
const apiKey = await getApiKey(account.id);
|
||||
results.push({
|
||||
accountId: account.id,
|
||||
hasKey: !!apiKey,
|
||||
keyMasked: maskApiKey(apiKey),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Read an account's API key. */
|
||||
async getAccountApiKey(accountId: string): Promise<string | null> {
|
||||
return this._getProviderApiKeyInternal(accountId);
|
||||
}
|
||||
|
||||
/** Check whether an account has an API key stored. */
|
||||
async hasAccountApiKey(accountId: string): Promise<boolean> {
|
||||
return this._hasProviderApiKeyInternal(accountId);
|
||||
}
|
||||
|
||||
/** Delete only an account's stored API key while keeping its model configuration. */
|
||||
async deleteAccountApiKey(accountId: string): Promise<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return deleteApiKey(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() and map account data in callers.
|
||||
*/
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
|
||||
return this._listProvidersFromAccountsInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccountsKeyInfo() + the account snapshot API.
|
||||
*/
|
||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccountsKeyInfo');
|
||||
return this._listProvidersWithKeyInfoInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccount(accountId).
|
||||
*/
|
||||
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
|
||||
return this._getProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use createAccount()/updateAccount().
|
||||
*/
|
||||
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
||||
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
|
||||
return this._saveProviderInternal(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use deleteAccount(accountId).
|
||||
*/
|
||||
async deleteLegacyProvider(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProvider', 'deleteAccount');
|
||||
return this._deleteProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use setDefaultAccount(accountId).
|
||||
*/
|
||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
|
||||
return this._setDefaultProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getDefaultAccountId().
|
||||
*/
|
||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
|
||||
return this._getDefaultProviderInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
|
||||
return this._setProviderApiKeyInternal(providerId, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccountApiKey(accountId).
|
||||
*/
|
||||
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getAccountApiKey');
|
||||
return this._getProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
*/
|
||||
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
|
||||
return this._deleteProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use hasAccountApiKey(accountId).
|
||||
*/
|
||||
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'hasAccountApiKey');
|
||||
return this._hasProviderApiKeyInternal(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;
|
||||
}
|
||||
104
electron/services/providers/provider-store.ts
Normal file
104
electron/services/providers/provider-store.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types';
|
||||
import { getProviderDefinition } from '../../shared/providers/registry';
|
||||
import { getNianCodeProviderStore } from './store-instance';
|
||||
|
||||
|
||||
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.apiProtocol || (config.type === 'custom' || config.type === 'ollama'
|
||||
? 'openai-completions'
|
||||
: getProviderDefinition(config.type)?.providerConfig?.api),
|
||||
headers: config.headers,
|
||||
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,
|
||||
apiProtocol: account.apiProtocol,
|
||||
headers: account.headers,
|
||||
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 getNianCodeProviderStore();
|
||||
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 getNianCodeProviderStore();
|
||||
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 getNianCodeProviderStore();
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
accounts[account.id] = account;
|
||||
store.set('providerAccounts', accounts);
|
||||
}
|
||||
|
||||
export async function deleteProviderAccount(accountId: string): Promise<void> {
|
||||
const store = await getNianCodeProviderStore();
|
||||
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 getNianCodeProviderStore();
|
||||
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 getNianCodeProviderStore();
|
||||
return store.get('defaultProviderAccountId') as string | undefined;
|
||||
}
|
||||
399
electron/services/providers/provider-validation.ts
Normal file
399
electron/services/providers/provider-validation.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import { getProviderConfig } from '../../utils/provider-registry';
|
||||
|
||||
type ValidationProfile =
|
||||
| 'openai-completions'
|
||||
| 'openai-responses'
|
||||
| 'google-query-key'
|
||||
| 'anthropic-header'
|
||||
| 'openrouter'
|
||||
| 'none';
|
||||
|
||||
type ValidationResult = { valid: boolean; error?: string; status?: number };
|
||||
type ClassifiedValidationResult = ValidationResult & { authFailure?: boolean };
|
||||
|
||||
const AUTH_ERROR_PATTERN = /\b(unauthorized|forbidden|access denied|invalid api key|api key invalid|incorrect api key|api key incorrect|authentication failed|auth failed|invalid credential|credential invalid|invalid signature|signature invalid|invalid access token|access token invalid|invalid bearer token|bearer token invalid|access token expired)\b|鉴权失败|認証失敗|认证失败|無效密鑰|无效密钥|密钥无效|密鑰無效|憑證無效|凭证无效/i;
|
||||
const AUTH_ERROR_CODE_PATTERN = /\b(unauthorized|forbidden|access[_-]?denied|invalid[_-]?api[_-]?key|api[_-]?key[_-]?invalid|incorrect[_-]?api[_-]?key|api[_-]?key[_-]?incorrect|authentication[_-]?failed|auth[_-]?failed|invalid[_-]?credential|credential[_-]?invalid|invalid[_-]?signature|signature[_-]?invalid|invalid[_-]?access[_-]?token|access[_-]?token[_-]?invalid|invalid[_-]?bearer[_-]?token|bearer[_-]?token[_-]?invalid|access[_-]?token[_-]?expired|invalid[_-]?token|token[_-]?invalid|token[_-]?expired)\b/i;
|
||||
|
||||
function logValidationStatus(provider: string, status: number): void {
|
||||
console.log(`[niancode-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 resolveOpenAiProbeUrls(
|
||||
baseUrl: string,
|
||||
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||
): { modelsUrl: string; probeUrl: string } {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const endpointSuffixPattern = /(\/responses?|\/chat\/completions)$/;
|
||||
const rootBase = normalizedBase.replace(endpointSuffixPattern, '');
|
||||
const modelsUrl = buildOpenAiModelsUrl(rootBase);
|
||||
|
||||
if (apiProtocol === 'openai-responses') {
|
||||
const probeUrl = /(\/responses?)$/.test(normalizedBase)
|
||||
? normalizedBase
|
||||
: `${rootBase}/responses`;
|
||||
return { modelsUrl, probeUrl };
|
||||
}
|
||||
|
||||
const probeUrl = /\/chat\/completions$/.test(normalizedBase)
|
||||
? normalizedBase
|
||||
: `${rootBase}/chat/completions`;
|
||||
return { modelsUrl, probeUrl };
|
||||
}
|
||||
|
||||
function logValidationRequest(
|
||||
provider: string,
|
||||
method: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): void {
|
||||
console.log(
|
||||
`[niancode-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getValidationProfile(
|
||||
providerType: string,
|
||||
options?: { apiProtocol?: string }
|
||||
): ValidationProfile {
|
||||
const providerApi = options?.apiProtocol || getProviderConfig(providerType)?.api;
|
||||
if (providerApi === 'anthropic-messages') {
|
||||
return 'anthropic-header';
|
||||
}
|
||||
if (providerApi === 'openai-responses') {
|
||||
return 'openai-responses';
|
||||
}
|
||||
if (providerApi === 'openai-completions') {
|
||||
return 'openai-completions';
|
||||
}
|
||||
|
||||
switch (providerType) {
|
||||
case 'anthropic':
|
||||
return 'anthropic-header';
|
||||
case 'google':
|
||||
return 'google-query-key';
|
||||
case 'openrouter':
|
||||
return 'openrouter';
|
||||
case 'ollama':
|
||||
return 'none';
|
||||
default:
|
||||
return 'openai-completions';
|
||||
}
|
||||
}
|
||||
|
||||
async function performProviderValidationRequest(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ClassifiedValidationResult> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const result = classifyAuthResponse(response.status, data);
|
||||
return { ...result, status: response.status };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function classifyAuthResponse(
|
||||
status: number,
|
||||
data: unknown,
|
||||
) : ClassifiedValidationResult {
|
||||
const obj = data as {
|
||||
error?: { message?: string; code?: string };
|
||||
message?: string;
|
||||
code?: string;
|
||||
} | null;
|
||||
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
|
||||
const code = obj?.error?.code || obj?.code;
|
||||
const hasAuthCode = typeof code === 'string' && AUTH_ERROR_CODE_PATTERN.test(code);
|
||||
|
||||
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', authFailure: true };
|
||||
}
|
||||
if (status === 400 && (AUTH_ERROR_PATTERN.test(msg) || hasAuthCode)) {
|
||||
const error = hasAuthCode && msg === `API error: ${status}`
|
||||
? `Invalid API key (${code})`
|
||||
: msg || 'Invalid API key';
|
||||
return { valid: false, error, authFailure: true };
|
||||
}
|
||||
|
||||
return { valid: false, error: msg };
|
||||
}
|
||||
|
||||
function shouldFallbackFromModelsProbe(result: ClassifiedValidationResult): boolean {
|
||||
if (result.valid || result.status === undefined) return false;
|
||||
if (result.status === 401 || result.status === 403) return false;
|
||||
if (result.authFailure) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function classifyProbeResponse(
|
||||
status: number,
|
||||
data: unknown,
|
||||
): ClassifiedValidationResult {
|
||||
const classified = classifyAuthResponse(status, data);
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
return { valid: true, status };
|
||||
}
|
||||
if (status === 429) {
|
||||
return { valid: true, status };
|
||||
}
|
||||
if (status === 400 && !classified.authFailure) {
|
||||
return { valid: true, status };
|
||||
}
|
||||
return { ...classified, status };
|
||||
}
|
||||
|
||||
async function validateOpenAiCompatibleKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
apiProtocol: 'openai-completions' | 'openai-responses',
|
||||
baseUrl?: string,
|
||||
): Promise<ValidationResult> {
|
||||
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, probeUrl } = resolveOpenAiProbeUrls(trimmedBaseUrl, apiProtocol);
|
||||
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
||||
|
||||
if (shouldFallbackFromModelsProbe(modelsResult)) {
|
||||
console.log(
|
||||
`[niancode-validate] ${providerType} /models returned ${modelsResult.status}, falling back to ${apiProtocol} probe`,
|
||||
);
|
||||
if (apiProtocol === 'openai-responses') {
|
||||
return await performResponsesProbe(providerType, probeUrl, headers);
|
||||
}
|
||||
return await performChatCompletionsProbe(providerType, probeUrl, headers);
|
||||
}
|
||||
|
||||
return modelsResult;
|
||||
}
|
||||
|
||||
async function performResponsesProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
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',
|
||||
input: 'hi',
|
||||
}),
|
||||
});
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return classifyProbeResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performChatCompletionsProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
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(() => ({}));
|
||||
return classifyProbeResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performAnthropicMessagesProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<ValidationResult> {
|
||||
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(() => ({}));
|
||||
return classifyProbeResponse(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<ValidationResult> {
|
||||
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<ValidationResult> {
|
||||
const rawBase = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
||||
const base = rawBase.endsWith('/v1') ? rawBase : `${rawBase}/v1`;
|
||||
const url = `${base}/models?limit=1`;
|
||||
const headers = {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
};
|
||||
|
||||
const modelsResult = await performProviderValidationRequest(providerType, url, headers);
|
||||
|
||||
// If the endpoint doesn't implement /models (like Minimax Anthropic compatibility), fallback to a /messages probe.
|
||||
if (
|
||||
modelsResult.status === 404 ||
|
||||
modelsResult.status === 400 ||
|
||||
modelsResult.error?.includes('API error: 404') ||
|
||||
modelsResult.error?.includes('API error: 400')
|
||||
) {
|
||||
console.log(
|
||||
`[niancode-validate] ${providerType} /models returned error, falling back to /messages probe`,
|
||||
);
|
||||
const messagesUrl = `${base}/messages`;
|
||||
return await performAnthropicMessagesProbe(providerType, messagesUrl, headers);
|
||||
}
|
||||
|
||||
return modelsResult;
|
||||
}
|
||||
|
||||
async function validateOpenRouterKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
): Promise<ValidationResult> {
|
||||
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; apiProtocol?: string },
|
||||
): Promise<ValidationResult> {
|
||||
const profile = getValidationProfile(providerType, options);
|
||||
const resolvedBaseUrl = options?.baseUrl || getProviderConfig(providerType)?.baseUrl;
|
||||
|
||||
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-completions':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
providerType,
|
||||
trimmedKey,
|
||||
'openai-completions',
|
||||
resolvedBaseUrl,
|
||||
);
|
||||
case 'openai-responses':
|
||||
return await validateOpenAiCompatibleKey(
|
||||
providerType,
|
||||
trimmedKey,
|
||||
'openai-responses',
|
||||
resolvedBaseUrl,
|
||||
);
|
||||
case 'google-query-key':
|
||||
return await validateGoogleQueryKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
case 'anthropic-header':
|
||||
return await validateAnthropicHeaderKey(providerType, trimmedKey, resolvedBaseUrl);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
23
electron/services/providers/store-instance.ts
Normal file
23
electron/services/providers/store-instance.ts
Normal 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 getNianCodeProviderStore() {
|
||||
if (!providerStore) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
providerStore = new Store({
|
||||
name: 'niancode-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;
|
||||
}
|
||||
Reference in New Issue
Block a user