feat(provider): support new provider api in clawx (#938)

This commit is contained in:
Haze
2026-04-29 19:42:42 +08:00
committed by GitHub
parent 54b75ded19
commit 1fc1e20ec4
9 changed files with 732 additions and 181 deletions
+123 -20
View File
@@ -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;
}
+131 -34
View File
@@ -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> {
-1
View File
@@ -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==}
+32 -3
View File
@@ -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 }),
});
+152 -3
View File
@@ -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
View File
@@ -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;
}
+23 -2
View File
@@ -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({});
});
});
+143 -1
View File
@@ -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');
});
});