277 lines
9.8 KiB
TypeScript
277 lines
9.8 KiB
TypeScript
import { hostApiFetch } from '@/lib/host-api';
|
|
import type {
|
|
ProviderAccount,
|
|
ProviderType,
|
|
ProviderVendorInfo,
|
|
ProviderWithKeyInfo,
|
|
} from '@/lib/providers';
|
|
|
|
export interface ProviderSnapshot {
|
|
accounts: ProviderAccount[];
|
|
statuses: ProviderWithKeyInfo[];
|
|
vendors: ProviderVendorInfo[];
|
|
defaultAccountId: string | null;
|
|
}
|
|
|
|
export interface ProviderListItem {
|
|
account: ProviderAccount;
|
|
vendor?: ProviderVendorInfo;
|
|
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> {
|
|
// 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'),
|
|
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 ?? null,
|
|
};
|
|
}
|
|
|
|
export function hasConfiguredCredentials(
|
|
account: ProviderAccount,
|
|
status?: ProviderWithKeyInfo,
|
|
): boolean {
|
|
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
|
|
return true;
|
|
}
|
|
return status?.hasKey ?? false;
|
|
}
|
|
|
|
export function pickPreferredAccount(
|
|
accounts: ProviderAccount[],
|
|
defaultAccountId: string | null,
|
|
vendorId: ProviderType | string,
|
|
statusMap: Map<string, ProviderWithKeyInfo>,
|
|
): ProviderAccount | null {
|
|
const sameVendor = accounts.filter((account) => account.vendorId === vendorId);
|
|
if (sameVendor.length === 0) return null;
|
|
|
|
return (
|
|
(defaultAccountId ? sameVendor.find((account) => account.id === defaultAccountId) : undefined)
|
|
|| sameVendor.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id)))
|
|
|| sameVendor[0]
|
|
);
|
|
}
|
|
|
|
export function buildProviderAccountId(
|
|
vendorId: ProviderType,
|
|
existingAccountId: string | null,
|
|
vendors: ProviderVendorInfo[],
|
|
): string {
|
|
if (existingAccountId) {
|
|
return existingAccountId;
|
|
}
|
|
|
|
const vendor = vendors.find((candidate) => candidate.id === vendorId);
|
|
const supportsMultipleAccounts = vendor?.supportsMultipleAccounts ?? true;
|
|
return supportsMultipleAccounts ? `${vendorId}-${crypto.randomUUID()}` : vendorId;
|
|
}
|
|
|
|
export function legacyProviderToAccount(provider: ProviderWithKeyInfo): ProviderAccount {
|
|
return {
|
|
id: provider.id,
|
|
vendorId: provider.type,
|
|
label: provider.name,
|
|
authMode: provider.type === 'ollama' ? 'local' : 'api_key',
|
|
baseUrl: provider.baseUrl,
|
|
headers: provider.headers,
|
|
model: provider.model,
|
|
fallbackModels: provider.fallbackModels,
|
|
fallbackAccountIds: provider.fallbackProviderIds,
|
|
enabled: provider.enabled,
|
|
isDefault: false,
|
|
createdAt: provider.createdAt,
|
|
updatedAt: provider.updatedAt,
|
|
};
|
|
}
|
|
|
|
export function buildProviderListItems(
|
|
accounts: ProviderAccount[],
|
|
statuses: ProviderWithKeyInfo[],
|
|
vendors: ProviderVendorInfo[],
|
|
defaultAccountId: string | null,
|
|
): ProviderListItem[] {
|
|
const safeAccounts = accounts ?? [];
|
|
const safeStatuses = statuses ?? [];
|
|
const safeVendors = vendors ?? [];
|
|
const vendorMap = new Map(safeVendors.map((vendor) => [vendor.id, vendor]));
|
|
const statusMap = new Map(safeStatuses.map((status) => [status.id, status]));
|
|
|
|
if (safeAccounts.length > 0) {
|
|
return safeAccounts
|
|
.map((account) => ({
|
|
account,
|
|
vendor: vendorMap.get(account.vendorId),
|
|
status: statusMap.get(account.id),
|
|
}))
|
|
.sort((left, right) => {
|
|
if (left.account.id === defaultAccountId) return -1;
|
|
if (right.account.id === defaultAccountId) return 1;
|
|
return right.account.updatedAt.localeCompare(left.account.updatedAt);
|
|
});
|
|
}
|
|
|
|
return safeStatuses.map((status) => ({
|
|
account: legacyProviderToAccount(status),
|
|
vendor: vendorMap.get(status.type),
|
|
status,
|
|
}));
|
|
}
|