mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
feat(provider): support new provider api in clawx (#938)
This commit is contained in:
@@ -94,9 +94,112 @@ export async function handleProviderRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── New account-based companion endpoints ─────────────────────────
|
||||
// Exposed alongside the existing /api/provider-accounts surface so the
|
||||
// renderer (and any future external client) can drop the legacy
|
||||
// /api/providers paths without losing functionality. Specific paths
|
||||
// must be matched BEFORE the generic /api/provider-accounts/:id rule
|
||||
// below to avoid being captured as account ids.
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/key-info' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listAccountsKeyInfo());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/validate' && req.method === 'POST') {
|
||||
try {
|
||||
// Accept legacy `providerId` as a fallback so external clients that
|
||||
// migrate by URL alone (without renaming their request body) continue
|
||||
// to work. The renderer always sends all three fields; older callers
|
||||
// may send only `providerId`.
|
||||
const body = await parseJsonBody<{
|
||||
accountId?: string;
|
||||
vendorId?: string;
|
||||
providerId?: string;
|
||||
apiKey: string;
|
||||
options?: { baseUrl?: string; apiProtocol?: string };
|
||||
}>(req);
|
||||
const accountId = body.accountId || body.vendorId || body.providerId || '';
|
||||
const account = accountId ? await providerService.getAccount(accountId) : null;
|
||||
const providerType = account?.vendorId || body.vendorId || body.providerId || accountId;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = body.options?.baseUrl || account?.baseUrl || registryBaseUrl;
|
||||
const resolvedProtocol = body.options?.apiProtocol || account?.apiProtocol;
|
||||
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, {
|
||||
baseUrl: resolvedBaseUrl,
|
||||
apiProtocol: resolvedProtocol,
|
||||
}));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { valid: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/oauth/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
provider: OAuthProviderType | BrowserOAuthProviderType;
|
||||
region?: 'global' | 'cn';
|
||||
accountId?: string;
|
||||
label?: string;
|
||||
}>(req);
|
||||
if (body.provider === 'google' || body.provider === 'openai') {
|
||||
await browserOAuthManager.startFlow(body.provider, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
} else {
|
||||
await deviceOAuthManager.startFlow(body.provider, body.region, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/oauth/cancel' && req.method === 'POST') {
|
||||
try {
|
||||
await deviceOAuthManager.stopFlow();
|
||||
await browserOAuthManager.stopFlow();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/oauth/submit' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ code: string }>(req);
|
||||
const accepted = browserOAuthManager.submitManualCode(body.code || '');
|
||||
if (!accepted) {
|
||||
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
sendJson(res, 200, await providerService.getAccount(accountId));
|
||||
const remainder = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
if (remainder.endsWith('/api-key')) {
|
||||
const accountId = remainder.slice(0, -'/api-key'.length);
|
||||
sendJson(res, 200, { apiKey: await providerService.getAccountApiKey(accountId) });
|
||||
return true;
|
||||
}
|
||||
if (remainder.endsWith('/has-api-key')) {
|
||||
const accountId = remainder.slice(0, -'/has-api-key'.length);
|
||||
sendJson(res, 200, { hasKey: await providerService.hasAccountApiKey(accountId) });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, await providerService.getAccount(remainder));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -133,7 +236,7 @@ export async function handleProviderRoutes(
|
||||
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
|
||||
: undefined;
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(accountId);
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
@@ -158,13 +261,13 @@ export async function handleProviderRoutes(
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers');
|
||||
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
|
||||
sendJson(res, 200, await providerService._listProvidersWithKeyInfoInternal());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers/default');
|
||||
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
|
||||
sendJson(res, 200, { providerId: await providerService._getDefaultProviderInternal() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -172,12 +275,12 @@ export async function handleProviderRoutes(
|
||||
logLegacyProviderRoute('PUT /api/providers/default');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||
const currentDefault = await providerService.getDefaultLegacyProvider();
|
||||
const currentDefault = await providerService._getDefaultProviderInternal();
|
||||
if (currentDefault === body.providerId) {
|
||||
sendJson(res, 200, { success: true, noChange: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.setDefaultLegacyProvider(body.providerId);
|
||||
await providerService._setDefaultProviderInternal(body.providerId);
|
||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
@@ -190,7 +293,7 @@ export async function handleProviderRoutes(
|
||||
logLegacyProviderRoute('POST /api/providers/validate');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
|
||||
const provider = await providerService.getLegacyProvider(body.providerId);
|
||||
const provider = await providerService._getProviderInternal(body.providerId);
|
||||
const providerType = provider?.type || body.providerId;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||
@@ -262,11 +365,11 @@ export async function handleProviderRoutes(
|
||||
try {
|
||||
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
||||
const config = body.config;
|
||||
await providerService.saveLegacyProvider(config);
|
||||
await providerService._saveProviderInternal(config);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
@@ -283,15 +386,15 @@ export async function handleProviderRoutes(
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
if (providerId.endsWith('/api-key')) {
|
||||
const actualId = providerId.slice(0, -('/api-key'.length));
|
||||
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
|
||||
sendJson(res, 200, { apiKey: await providerService._getProviderApiKeyInternal(actualId) });
|
||||
return true;
|
||||
}
|
||||
if (providerId.endsWith('/has-api-key')) {
|
||||
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
||||
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
|
||||
sendJson(res, 200, { hasKey: await providerService._hasProviderApiKeyInternal(actualId) });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
|
||||
sendJson(res, 200, await providerService._getProviderInternal(providerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -300,7 +403,7 @@ export async function handleProviderRoutes(
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
||||
return true;
|
||||
@@ -311,14 +414,14 @@ export async function handleProviderRoutes(
|
||||
return true;
|
||||
}
|
||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||
await providerService.saveLegacyProvider(nextConfig);
|
||||
await providerService._saveProviderInternal(nextConfig);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
|
||||
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
} else {
|
||||
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
}
|
||||
}
|
||||
@@ -334,14 +437,14 @@ export async function handleProviderRoutes(
|
||||
logLegacyProviderRoute('DELETE /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||
await providerService._deleteProviderApiKeyInternal(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
await providerService._deleteProviderInternal(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -266,6 +266,12 @@ async function syncProviderSecretToRuntime(
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, trimmedKey);
|
||||
} else {
|
||||
// An explicit empty string means the caller wants to clear the key.
|
||||
// Mirror that intent into OpenClaw auth-profiles so the gateway no
|
||||
// longer authenticates with the stale value (matches the explicit
|
||||
// delete branch in the legacy /api/providers/:id PUT handler).
|
||||
await removeProviderKeyFromOpenClaw(runtimeProviderKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,21 +276,22 @@ export class ProviderService {
|
||||
return deleteProvider(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() and map account data in callers.
|
||||
*/
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProviders', 'listAccounts');
|
||||
// ── 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<ProviderConfig[]> {
|
||||
const accounts = await this.listAccounts();
|
||||
return accounts.map(providerAccountToConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use listAccounts() + secret-store based key summary.
|
||||
*/
|
||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||
logLegacyProviderApiUsage('listLegacyProvidersWithKeyInfo', 'listAccounts');
|
||||
const providers = await this.listLegacyProviders();
|
||||
/** 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);
|
||||
@@ -303,21 +304,15 @@ export class ProviderService {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getAccount(accountId).
|
||||
*/
|
||||
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProvider', 'getAccount');
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use createAccount()/updateAccount().
|
||||
*/
|
||||
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
||||
logLegacyProviderApiUsage('saveLegacyProvider', 'createAccount/updateAccount');
|
||||
/** Internal: upsert a legacy provider config (creates or updates the account). */
|
||||
async _saveProviderInternal(config: ProviderConfig): Promise<void> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const account = providerConfigToAccount(config);
|
||||
const existing = await getProviderAccount(config.id);
|
||||
@@ -328,14 +323,116 @@ export class ProviderService {
|
||||
await this.createAccount(account);
|
||||
}
|
||||
|
||||
/** Internal: delete a provider account by id. */
|
||||
async _deleteProviderInternal(providerId: string): Promise<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await this.deleteAccount(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);
|
||||
}
|
||||
|
||||
// ── 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<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 (clean alternative to getLegacyProviderApiKey). */
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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<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');
|
||||
await ensureProviderStoreMigrated();
|
||||
await this.deleteAccount(providerId);
|
||||
return true;
|
||||
return this._deleteProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,7 +440,7 @@ export class ProviderService {
|
||||
*/
|
||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||
logLegacyProviderApiUsage('setDefaultLegacyProvider', 'setDefaultAccount');
|
||||
await this.setDefaultAccount(providerId);
|
||||
return this._setDefaultProviderInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,7 +448,7 @@ export class ProviderService {
|
||||
*/
|
||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||
logLegacyProviderApiUsage('getDefaultLegacyProvider', 'getDefaultAccountId');
|
||||
return this.getDefaultAccountId();
|
||||
return this._getDefaultProviderInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,15 +456,15 @@ export class ProviderService {
|
||||
*/
|
||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('setLegacyProviderApiKey', 'setProviderSecret(accountId, api_key)');
|
||||
return storeApiKey(providerId, apiKey);
|
||||
return this._setProviderApiKeyInternal(providerId, apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
* @deprecated Use getAccountApiKey(accountId).
|
||||
*/
|
||||
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return getApiKey(providerId);
|
||||
logLegacyProviderApiUsage('getLegacyProviderApiKey', 'getAccountApiKey');
|
||||
return this._getProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,15 +472,15 @@ export class ProviderService {
|
||||
*/
|
||||
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('deleteLegacyProviderApiKey', 'deleteProviderSecret(accountId)');
|
||||
return deleteApiKey(providerId);
|
||||
return this._deleteProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use secret-store APIs by accountId.
|
||||
* @deprecated Use hasAccountApiKey(accountId).
|
||||
*/
|
||||
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'getProviderSecret(accountId)');
|
||||
return hasApiKey(providerId);
|
||||
logLegacyProviderApiUsage('hasLegacyProviderApiKey', 'hasAccountApiKey');
|
||||
return this._hasProviderApiKeyInternal(providerId);
|
||||
}
|
||||
|
||||
async setDefaultAccount(accountId: string): Promise<void> {
|
||||
|
||||
Generated
-1
@@ -3096,7 +3096,6 @@ packages:
|
||||
basic-ftp@5.2.0:
|
||||
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: Security vulnerability fixed in 5.2.1, please upgrade
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
buildProviderAccountId,
|
||||
buildProviderListItems,
|
||||
hasConfiguredCredentials,
|
||||
isHostApiRouteMissing,
|
||||
type ProviderListItem,
|
||||
} from '@/lib/provider-accounts';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -97,6 +98,34 @@ function getUserAgentHeader(headers?: Record<string, string>): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `hostApiFetch` for OAuth provider routes so we always try the new
|
||||
* `/api/provider-accounts/oauth/...` endpoints first and fall back to the
|
||||
* legacy `/api/providers/oauth/...` paths when running against an older
|
||||
* Host API build that returns a "no route for" body for the new routes.
|
||||
*
|
||||
* This keeps the renderer compatible with both:
|
||||
* - Newer Host APIs that have migrated OAuth under provider-accounts.
|
||||
* - Older Host APIs that only expose the legacy provider-namespace OAuth.
|
||||
*/
|
||||
async function hostApiFetchOAuth<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
const legacyPath = path.replace('/api/provider-accounts/oauth/', '/api/providers/oauth/');
|
||||
let result: T;
|
||||
try {
|
||||
result = await hostApiFetch<T>(path, init);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/404|not\s+found/i.test(message) || legacyPath === path) {
|
||||
throw error;
|
||||
}
|
||||
return await hostApiFetch<T>(legacyPath, init);
|
||||
}
|
||||
if (isHostApiRouteMissing(result) && legacyPath !== path) {
|
||||
return await hostApiFetch<T>(legacyPath, init);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeHeadersWithUserAgent(
|
||||
headers: Record<string, string> | undefined,
|
||||
userAgent: string,
|
||||
@@ -1099,7 +1128,7 @@ function AddProviderDialog({
|
||||
const accountId = supportsMultipleAccounts ? `${selectedType}-${crypto.randomUUID()}` : selectedType;
|
||||
const label = name || (typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name) || selectedType;
|
||||
pendingOAuthRef.current = { accountId, label };
|
||||
await hostApiFetch('/api/providers/oauth/start', {
|
||||
await hostApiFetchOAuth('/api/provider-accounts/oauth/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: selectedType, accountId, label }),
|
||||
});
|
||||
@@ -1116,7 +1145,7 @@ function AddProviderDialog({
|
||||
setManualCodeInput('');
|
||||
setOauthError(null);
|
||||
pendingOAuthRef.current = null;
|
||||
await hostApiFetch('/api/providers/oauth/cancel', {
|
||||
await hostApiFetchOAuth('/api/provider-accounts/oauth/cancel', {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
@@ -1125,7 +1154,7 @@ function AddProviderDialog({
|
||||
const value = manualCodeInput.trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
await hostApiFetch('/api/providers/oauth/submit', {
|
||||
await hostApiFetchOAuth('/api/provider-accounts/oauth/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: value }),
|
||||
});
|
||||
|
||||
@@ -19,19 +19,168 @@ export interface ProviderListItem {
|
||||
status?: ProviderWithKeyInfo;
|
||||
}
|
||||
|
||||
export interface ProviderAccountKeyInfo {
|
||||
accountId: string;
|
||||
hasKey: boolean;
|
||||
keyMasked: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the legacy `ProviderWithKeyInfo` shape (`ProviderConfig & { hasKey, keyMasked }`)
|
||||
* from a `ProviderAccount` and its associated key metadata.
|
||||
*
|
||||
* The renderer keeps emitting this shape via `useProviderStore.statuses` for
|
||||
* backward compatibility with consumers (e.g. `pages/Agents/index.tsx`,
|
||||
* `buildProviderListItems`) that look up a status entry by accountId.
|
||||
*
|
||||
* Equivalent to the backend's `providerAccountToConfig` + `hasKey/keyMasked`
|
||||
* augmentation, kept in lockstep so renderer-side derivation matches the
|
||||
* legacy `/api/providers` payload.
|
||||
*/
|
||||
export function accountToProviderWithKeyInfo(
|
||||
account: ProviderAccount,
|
||||
keyInfo: { hasKey: boolean; keyMasked: string | null } | undefined,
|
||||
): ProviderWithKeyInfo {
|
||||
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,
|
||||
hasKey: keyInfo?.hasKey ?? false,
|
||||
keyMasked: keyInfo?.keyMasked ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compat helper for older fixtures and any external callers still
|
||||
* publishing `ProviderWithKeyInfo[]` payloads via the legacy `/api/providers`
|
||||
* route.
|
||||
*/
|
||||
function fallbackStatusToAccount(status: ProviderWithKeyInfo): ProviderAccount {
|
||||
return {
|
||||
id: status.id,
|
||||
vendorId: status.type,
|
||||
label: status.name,
|
||||
authMode: status.type === 'ollama' ? 'local' : 'api_key',
|
||||
baseUrl: status.baseUrl,
|
||||
apiProtocol: status.apiProtocol,
|
||||
headers: status.headers,
|
||||
model: status.model,
|
||||
fallbackModels: status.fallbackModels,
|
||||
fallbackAccountIds: status.fallbackProviderIds,
|
||||
enabled: status.enabled,
|
||||
isDefault: false,
|
||||
createdAt: status.createdAt,
|
||||
updatedAt: status.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `hostApiFetch` returns the response body even on non-2xx HTTP status, so
|
||||
* a 404 from the Host API surfaces as `{ success: false, error: "No route
|
||||
* for GET ..." }` rather than a thrown error. Detect that shape so we can
|
||||
* trigger the legacy fallback path when an older Host API build is missing
|
||||
* the new account-companion routes (key-info, validate, oauth, api-key).
|
||||
*/
|
||||
function isRouteNotFoundBody(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.success !== false) return false;
|
||||
const error = record.error;
|
||||
return typeof error === 'string' && /no\s+route\s+for/i.test(error);
|
||||
}
|
||||
|
||||
export function isHostApiRouteMissing(value: unknown): boolean {
|
||||
return isRouteNotFoundBody(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects thrown errors that look like a missing-route response (currently
|
||||
* only emitted by the browser-fallback path in `host-api.ts`, which DOES
|
||||
* throw on non-2xx). Returns true so callers can collapse that case into
|
||||
* the same "use the legacy route" code path as the body-shape detection.
|
||||
*/
|
||||
function isRouteNotFoundError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return /no\s+route\s+for|404|not\s+found/i.test(error.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `hostApiFetch` so that a missing route (either a thrown 404 or the
|
||||
* `{ success: false, error: "No route ..." }` body) resolves to `null` and
|
||||
* any *other* error propagates. Avoids the previous `.catch(() => null)`
|
||||
* pattern which masked real failures (network outages, IPC unavailability,
|
||||
* malformed payloads) and left the user staring at an empty list.
|
||||
*/
|
||||
async function fetchAllowingMissingRoute<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const result = await hostApiFetch<T | { success: false; error: string }>(path);
|
||||
if (isRouteNotFoundBody(result)) {
|
||||
return null;
|
||||
}
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
if (isRouteNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProviderSnapshot(): Promise<ProviderSnapshot> {
|
||||
const [accounts, statuses, vendors, defaultInfo] = await Promise.all([
|
||||
// Primary path: read everything from the new /api/provider-accounts surface.
|
||||
// Only the key-info call tolerates a missing route (older Host API builds
|
||||
// predate it). All other endpoints have shipped for a while; if they fail,
|
||||
// the snapshot fails and the store surfaces the error to the UI rather
|
||||
// than presenting an empty/inconsistent provider list.
|
||||
const [accountsResult, keyInfoResult, vendors, defaultInfo] = await Promise.all([
|
||||
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'),
|
||||
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'),
|
||||
fetchAllowingMissingRoute<ProviderAccountKeyInfo[]>('/api/provider-accounts/key-info'),
|
||||
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'),
|
||||
hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'),
|
||||
]);
|
||||
|
||||
let accounts = accountsResult ?? [];
|
||||
let statuses: ProviderWithKeyInfo[];
|
||||
|
||||
if (Array.isArray(keyInfoResult)) {
|
||||
const keyInfoMap = new Map(
|
||||
keyInfoResult.map((entry) => [entry.accountId, entry] as const),
|
||||
);
|
||||
statuses = accounts.map((account) => accountToProviderWithKeyInfo(account, keyInfoMap.get(account.id)));
|
||||
} else {
|
||||
// ── Backward-compat fallback ────────────────────────────────────
|
||||
// Talking to an older Host API (no /api/provider-accounts/key-info
|
||||
// route). Use the legacy /api/providers payload as the status source
|
||||
// and synthesise accounts from it when the accounts list is empty
|
||||
// (e.g. pre-migration installs). Any non-route-missing error here
|
||||
// (network, IPC, parse) propagates so the UI can show it.
|
||||
const legacyStatusesRaw = await fetchAllowingMissingRoute<ProviderWithKeyInfo[]>('/api/providers');
|
||||
if (legacyStatusesRaw === null) {
|
||||
// Even the legacy route is missing — emit a single warn so the empty
|
||||
// list isn't silently misattributed to "no providers configured".
|
||||
console.warn('[provider-accounts] Both /api/provider-accounts/key-info and /api/providers are missing on this Host API; statuses will be empty.');
|
||||
}
|
||||
const legacyStatuses = Array.isArray(legacyStatusesRaw) ? legacyStatusesRaw : [];
|
||||
statuses = legacyStatuses;
|
||||
if (accounts.length === 0 && legacyStatuses.length > 0) {
|
||||
accounts = legacyStatuses.map(fallbackStatusToAccount);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
statuses,
|
||||
vendors,
|
||||
defaultAccountId: defaultInfo.accountId,
|
||||
defaultAccountId: defaultInfo?.accountId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+122
-117
@@ -13,6 +13,7 @@ import { normalizeProviderApiKeyInput } from '@/lib/providers';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
fetchProviderSnapshot,
|
||||
isHostApiRouteMissing,
|
||||
} from '@/lib/provider-accounts';
|
||||
|
||||
// Re-export types for consumers that imported from here
|
||||
@@ -100,26 +101,32 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchProviders: async () => get().refreshProviderSnapshot(),
|
||||
|
||||
|
||||
// Legacy ProviderConfig-shaped alias kept for backward compatibility
|
||||
// with any stale caller. Internally projects the legacy config payload
|
||||
// onto the new ProviderAccount surface and delegates to createAccount,
|
||||
// so we hit /api/provider-accounts instead of the deprecated
|
||||
// /api/providers POST route.
|
||||
addProvider: async (config, apiKey) => {
|
||||
try {
|
||||
const fullConfig: ProviderConfig = {
|
||||
...config,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
const now = new Date().toISOString();
|
||||
const account: ProviderAccount = {
|
||||
id: config.id,
|
||||
vendorId: config.type,
|
||||
label: config.name,
|
||||
authMode: config.type === 'ollama' ? 'local' : 'api_key',
|
||||
baseUrl: config.baseUrl,
|
||||
apiProtocol: config.apiProtocol,
|
||||
headers: config.headers,
|
||||
model: config.model,
|
||||
fallbackModels: config.fallbackModels,
|
||||
fallbackAccountIds: config.fallbackProviderIds,
|
||||
enabled: config.enabled,
|
||||
isDefault: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ config: fullConfig, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save provider');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().refreshProviderSnapshot();
|
||||
await get().createAccount(account, apiKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to add provider:', error);
|
||||
throw error;
|
||||
@@ -145,33 +152,23 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
},
|
||||
|
||||
addAccount: async (account, apiKey) => get().createAccount(account, apiKey),
|
||||
|
||||
|
||||
// Legacy ProviderConfig-shaped alias. Translates the partial ProviderConfig
|
||||
// patch into a ProviderAccount patch and routes through updateAccount so we
|
||||
// never hit the deprecated /api/providers/:id PUT route from the renderer.
|
||||
updateProvider: async (providerId, updates, apiKey) => {
|
||||
try {
|
||||
const existing = get().statuses.find((p) => p.id === providerId);
|
||||
if (!existing) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
const { hasKey: _hasKey, keyMasked: _keyMasked, ...providerConfig } = existing;
|
||||
|
||||
const updatedConfig: ProviderConfig = {
|
||||
...providerConfig,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates: updatedConfig, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().refreshProviderSnapshot();
|
||||
const accountUpdates: Partial<ProviderAccount> = {};
|
||||
if (updates.name !== undefined) accountUpdates.label = updates.name;
|
||||
if (updates.type !== undefined) accountUpdates.vendorId = updates.type;
|
||||
if (updates.baseUrl !== undefined) accountUpdates.baseUrl = updates.baseUrl;
|
||||
if (updates.apiProtocol !== undefined) accountUpdates.apiProtocol = updates.apiProtocol;
|
||||
if (updates.headers !== undefined) accountUpdates.headers = updates.headers;
|
||||
if (updates.model !== undefined) accountUpdates.model = updates.model;
|
||||
if (updates.fallbackModels !== undefined) accountUpdates.fallbackModels = updates.fallbackModels;
|
||||
if (updates.fallbackProviderIds !== undefined) accountUpdates.fallbackAccountIds = updates.fallbackProviderIds;
|
||||
if (updates.enabled !== undefined) accountUpdates.enabled = updates.enabled;
|
||||
await get().updateAccount(providerId, accountUpdates, apiKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to update provider:', error);
|
||||
throw error;
|
||||
@@ -195,24 +192,8 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider: async (providerId) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete provider');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete provider:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider: async (providerId) => get().removeAccount(providerId),
|
||||
|
||||
removeAccount: async (accountId) => {
|
||||
try {
|
||||
@@ -232,80 +213,52 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
},
|
||||
|
||||
deleteAccount: async (accountId) => get().removeAccount(accountId),
|
||||
|
||||
setApiKey: async (providerId, apiKey) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates: {}, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set API key');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to set API key:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy alias kept for in-flight callers; routes the call to the new
|
||||
// /api/provider-accounts/:id PUT endpoint via updateAccount, which is
|
||||
// semantically equivalent to "set API key without other changes".
|
||||
setApiKey: async (providerId, apiKey) => get().updateAccount(providerId, {}, apiKey),
|
||||
|
||||
updateProviderWithKey: async (providerId, updates, apiKey) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider');
|
||||
}
|
||||
|
||||
await get().refreshProviderSnapshot();
|
||||
const accountUpdates: Partial<ProviderAccount> = {};
|
||||
if (updates.name !== undefined) accountUpdates.label = updates.name;
|
||||
if (updates.type !== undefined) accountUpdates.vendorId = updates.type;
|
||||
if (updates.baseUrl !== undefined) accountUpdates.baseUrl = updates.baseUrl;
|
||||
if (updates.apiProtocol !== undefined) accountUpdates.apiProtocol = updates.apiProtocol;
|
||||
if (updates.headers !== undefined) accountUpdates.headers = updates.headers;
|
||||
if (updates.model !== undefined) accountUpdates.model = updates.model;
|
||||
if (updates.fallbackModels !== undefined) accountUpdates.fallbackModels = updates.fallbackModels;
|
||||
if (updates.fallbackProviderIds !== undefined) accountUpdates.fallbackAccountIds = updates.fallbackProviderIds;
|
||||
if (updates.enabled !== undefined) accountUpdates.enabled = updates.enabled;
|
||||
await get().updateAccount(providerId, accountUpdates, apiKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to update provider with key:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Legacy alias — the new account API exposes the same `apiKeyOnly=1`
|
||||
// contract, so we just route through it.
|
||||
deleteApiKey: async (providerId) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(
|
||||
`/api/providers/${encodeURIComponent(providerId)}?apiKeyOnly=1`,
|
||||
`/api/provider-accounts/${encodeURIComponent(providerId)}?apiKeyOnly=1`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete API key');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultProvider: async (providerId) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/providers/default', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set default provider');
|
||||
}
|
||||
|
||||
set({ defaultAccountId: providerId });
|
||||
} catch (error) {
|
||||
console.error('Failed to set default provider:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultProvider: async (providerId) => get().setDefaultAccount(providerId),
|
||||
|
||||
setDefaultAccount: async (accountId) => {
|
||||
try {
|
||||
@@ -328,22 +281,74 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
validateAccountApiKey: async (providerId, apiKey, options) => {
|
||||
try {
|
||||
const normalizedApiKey = normalizeProviderApiKeyInput(apiKey);
|
||||
const result = await hostApiFetch<{ valid: boolean; error?: string }>('/api/providers/validate', {
|
||||
// The new endpoint accepts both `accountId` (preferred) and a bare
|
||||
// `vendorId` (used during the Add-Provider flow when no account
|
||||
// exists yet). We always send `providerId` too so older Host API
|
||||
// builds that still own the legacy contract keep working when we
|
||||
// fall back to /api/providers/validate below.
|
||||
const fetchNew = async () => hostApiFetch<{ valid: boolean; error?: string }>('/api/provider-accounts/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accountId: providerId,
|
||||
vendorId: providerId,
|
||||
providerId,
|
||||
apiKey: normalizedApiKey,
|
||||
options,
|
||||
}),
|
||||
});
|
||||
const fetchLegacy = async () => hostApiFetch<{ valid: boolean; error?: string }>('/api/providers/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ providerId, apiKey: normalizedApiKey, options }),
|
||||
});
|
||||
return result;
|
||||
|
||||
let result: { valid: boolean; error?: string } | { success: false; error: string };
|
||||
try {
|
||||
result = await fetchNew();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /404|not\s+found/i.test(error.message)) {
|
||||
result = await fetchLegacy();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// hostApiFetch returns the body even for non-2xx (e.g. 404), so a
|
||||
// missing route surfaces as { success: false, error: "No route ..." }.
|
||||
// Detect that and fall back to the legacy endpoint before reporting
|
||||
// back to the caller.
|
||||
if (isHostApiRouteMissing(result)) {
|
||||
result = await fetchLegacy();
|
||||
}
|
||||
return result as { valid: boolean; error?: string };
|
||||
} catch (error) {
|
||||
return { valid: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
validateApiKey: async (providerId, apiKey, options) => get().validateAccountApiKey(providerId, apiKey, options),
|
||||
|
||||
|
||||
getAccountApiKey: async (providerId) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ apiKey: string | null }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`);
|
||||
return result.apiKey;
|
||||
const fetchNew = async () => hostApiFetch<{ apiKey: string | null } | { success: false; error: string }>(
|
||||
`/api/provider-accounts/${encodeURIComponent(providerId)}/api-key`,
|
||||
);
|
||||
const fetchLegacy = async () => hostApiFetch<{ apiKey: string | null }>(
|
||||
`/api/providers/${encodeURIComponent(providerId)}/api-key`,
|
||||
);
|
||||
|
||||
let result: { apiKey: string | null } | { success: false; error: string };
|
||||
try {
|
||||
result = await fetchNew();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /404|not\s+found/i.test(error.message)) {
|
||||
result = await fetchLegacy();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (isHostApiRouteMissing(result)) {
|
||||
result = await fetchLegacy();
|
||||
}
|
||||
return (result as { apiKey: string | null }).apiKey ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ test.describe('ClawX provider lifecycle', () => {
|
||||
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
|
||||
|
||||
let accounts: Array<Record<string, unknown>> = [];
|
||||
let keyInfo: Array<{ accountId: string; hasKey: boolean; keyMasked: string | null }> = [];
|
||||
let statuses: Array<Record<string, unknown>> = [];
|
||||
let defaultAccountId: string | null = null;
|
||||
|
||||
@@ -88,12 +89,13 @@ test.describe('ClawX provider lifecycle', () => {
|
||||
const method = request?.method ?? 'GET';
|
||||
const body = request?.body ? JSON.parse(request.body) : null;
|
||||
|
||||
// New account-based endpoints (preferred path).
|
||||
if (path === '/api/provider-accounts' && method === 'GET') return respond(accounts);
|
||||
if (path === '/api/providers' && method === 'GET') return respond(statuses);
|
||||
if (path === '/api/provider-accounts/key-info' && method === 'GET') return respond(keyInfo);
|
||||
if (path === '/api/provider-vendors' && method === 'GET') return respond([]);
|
||||
if (path === '/api/provider-accounts/default' && method === 'GET') return respond({ accountId: defaultAccountId });
|
||||
|
||||
if (path === '/api/providers/validate' && method === 'POST') {
|
||||
if (path === '/api/provider-accounts/validate' && method === 'POST') {
|
||||
if (body?.apiKey !== 'sk-lm-test') {
|
||||
return respond({ valid: false, error: `unexpected key: ${String(body?.apiKey)}` }, 400);
|
||||
}
|
||||
@@ -102,6 +104,12 @@ test.describe('ClawX provider lifecycle', () => {
|
||||
|
||||
if (path === '/api/provider-accounts' && method === 'POST') {
|
||||
accounts = [body.account];
|
||||
keyInfo = [{
|
||||
accountId: body.account.id,
|
||||
hasKey: Boolean(body.apiKey),
|
||||
keyMasked: body.apiKey ? 'sk-***' : null,
|
||||
}];
|
||||
// Keep statuses populated for any consumer still on the legacy path.
|
||||
statuses = [{
|
||||
id: body.account.id,
|
||||
name: body.account.label,
|
||||
@@ -122,6 +130,19 @@ test.describe('ClawX provider lifecycle', () => {
|
||||
return respond({ success: true });
|
||||
}
|
||||
|
||||
// ── Legacy compatibility shims ─────────────────────────────
|
||||
// Older renderer builds still reach for these. Keeping them
|
||||
// wired up here exercises the backward-compat path in the
|
||||
// route layer (it returns the same data, just without the
|
||||
// newer key-info payload structure).
|
||||
if (path === '/api/providers' && method === 'GET') return respond(statuses);
|
||||
if (path === '/api/providers/validate' && method === 'POST') {
|
||||
if (body?.apiKey !== 'sk-lm-test') {
|
||||
return respond({ valid: false, error: `unexpected key: ${String(body?.apiKey)}` }, 400);
|
||||
}
|
||||
return respond({ valid: true });
|
||||
}
|
||||
|
||||
return respond({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,13 @@ const mockHostApiFetch = vi.fn();
|
||||
|
||||
vi.mock('@/lib/provider-accounts', () => ({
|
||||
fetchProviderSnapshot: (...args: unknown[]) => mockFetchProviderSnapshot(...args),
|
||||
isHostApiRouteMissing: (value: unknown): boolean => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.success !== false) return false;
|
||||
const error = record.error;
|
||||
return typeof error === 'string' && /no\s+route\s+for/i.test(error);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
@@ -27,9 +34,11 @@ describe('useProviderStore – validateAccountApiKey()', () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
expect(mockHostApiFetch).toHaveBeenCalledWith('/api/providers/validate', {
|
||||
expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accountId: 'custom',
|
||||
vendorId: 'custom',
|
||||
providerId: 'custom',
|
||||
apiKey: 'sk-lm-test',
|
||||
options: {
|
||||
@@ -39,4 +48,137 @@ describe('useProviderStore – validateAccountApiKey()', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to legacy /api/providers/validate when the new route throws a 404', async () => {
|
||||
// The browser-fallback path of `hostApiFetch` (used in non-Electron
|
||||
// environments and surfaced by some IPC error normalisations) throws
|
||||
// on non-2xx HTTP. Make sure the renderer treats those as missing-route.
|
||||
mockHostApiFetch.mockRejectedValueOnce(new Error('404 Not Found'));
|
||||
mockHostApiFetch.mockResolvedValueOnce({ valid: true });
|
||||
|
||||
const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test', {
|
||||
baseUrl: 'http://127.0.0.1:1234/v1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object));
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
providerId: 'custom',
|
||||
apiKey: 'sk-lm-test',
|
||||
options: { baseUrl: 'http://127.0.0.1:1234/v1' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to legacy /api/providers/validate when the new route returns a route-not-found body', async () => {
|
||||
// The Electron IPC proxy never throws on HTTP 404 — it surfaces the
|
||||
// JSON body. Older Host API builds without the new validate route
|
||||
// therefore return `{ success: false, error: "No route for ..." }`.
|
||||
// The renderer must detect that body shape via `isHostApiRouteMissing`
|
||||
// and replay the request against the legacy route. This is the path
|
||||
// that actually runs in production today.
|
||||
mockHostApiFetch.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'No route for POST /api/provider-accounts/validate',
|
||||
});
|
||||
mockHostApiFetch.mockResolvedValueOnce({ valid: true });
|
||||
|
||||
const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test', {
|
||||
baseUrl: 'http://127.0.0.1:1234/v1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
expect(mockHostApiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object));
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
providerId: 'custom',
|
||||
apiKey: 'sk-lm-test',
|
||||
options: { baseUrl: 'http://127.0.0.1:1234/v1' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT fall back when the new route returns a real validation failure', async () => {
|
||||
// `{ valid: false, error: ... }` is a legitimate validation result —
|
||||
// it must NOT be confused with a missing-route body (whose discriminator
|
||||
// is `success: false`). Otherwise we would silently retry against the
|
||||
// legacy route and double-charge the upstream provider.
|
||||
mockHostApiFetch.mockResolvedValueOnce({ valid: false, error: 'API key is rejected' });
|
||||
|
||||
const result = await useProviderStore.getState().validateAccountApiKey('custom', 'sk-lm-test');
|
||||
|
||||
expect(result).toEqual({ valid: false, error: 'API key is rejected' });
|
||||
expect(mockHostApiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/validate', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProviderStore – getAccountApiKey()', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the key from the new account-namespaced endpoint by default', async () => {
|
||||
mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-stored-key' });
|
||||
|
||||
const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1');
|
||||
|
||||
expect(apiKey).toBe('sk-stored-key');
|
||||
expect(mockHostApiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/openai-account-1/api-key');
|
||||
});
|
||||
|
||||
it('falls back to legacy /api/providers/:id/api-key when the new route throws a 404', async () => {
|
||||
// Browser-fallback path: thrown 404.
|
||||
mockHostApiFetch.mockRejectedValueOnce(new Error('404 Not Found'));
|
||||
mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-legacy-key' });
|
||||
|
||||
const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1');
|
||||
|
||||
expect(apiKey).toBe('sk-legacy-key');
|
||||
expect(mockHostApiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/openai-account-1/api-key');
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/openai-account-1/api-key');
|
||||
});
|
||||
|
||||
it('falls back to legacy /api/providers/:id/api-key when the new route returns a route-not-found body', async () => {
|
||||
// Electron IPC proxy path: 404 surfaces as a "No route" body.
|
||||
mockHostApiFetch.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'No route for GET /api/provider-accounts/openai-account-1/api-key',
|
||||
});
|
||||
mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-legacy-key' });
|
||||
|
||||
const apiKey = await useProviderStore.getState().getAccountApiKey('openai-account-1');
|
||||
|
||||
expect(apiKey).toBe('sk-legacy-key');
|
||||
expect(mockHostApiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(1, '/api/provider-accounts/openai-account-1/api-key');
|
||||
expect(mockHostApiFetch).toHaveBeenNthCalledWith(2, '/api/providers/openai-account-1/api-key');
|
||||
});
|
||||
|
||||
it('returns null when the legacy fallback also reports no key', async () => {
|
||||
mockHostApiFetch.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'No route for GET /api/provider-accounts/missing-account/api-key',
|
||||
});
|
||||
mockHostApiFetch.mockResolvedValueOnce({ apiKey: null });
|
||||
|
||||
const apiKey = await useProviderStore.getState().getAccountApiKey('missing-account');
|
||||
|
||||
expect(apiKey).toBeNull();
|
||||
expect(mockHostApiFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('encodes the account id so colons and slashes survive the request', async () => {
|
||||
mockHostApiFetch.mockResolvedValueOnce({ apiKey: 'sk-stored-key' });
|
||||
|
||||
await useProviderStore.getState().getAccountApiKey('vendor:weird/id');
|
||||
|
||||
expect(mockHostApiFetch).toHaveBeenCalledWith('/api/provider-accounts/vendor%3Aweird%2Fid/api-key');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user