1import 'server-only';2import type {3 AsOfPayload,4 BenchmarkResult,5 BenchmarkRow,6 ChangeCategories,7 ChangeEvent,8 Claim,9 CompaniesPage,10 ComparePayload,11 DailyDigest,12 DiffPayload,13 EntityDetail,14 EntitySummary,15 ExploreType,16 GraphPayload,17 HardwareFit,18 Health,19 Methodology,20 ModelsPage,21 Page,22 Price,23 PriceIndex,24 ProviderRow,25 SearchPayload,26 SitemapPayload,27 SourceRef,28 SourceRow,29 Stats,30 StatsHistory,31 Suggestion,32 TimelinePayload,33 TrendingRow,34} from './types';3536/**37 * Typed fetch wrapper for the AI Atlas API (server components only — client code calls the same-origin `/api/v1/*`38 * rewrite through `src/lib/client-api.ts`).39 *40 * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store).41 * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages must render an42 * "Unavailable" state rather than crash (graceful degradation). 404 → `notFound()` in the page.43 * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel.44 */45export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, '');46const BASE = `${API_URL}/api/v1`;4748export class ApiError extends Error {49 readonly status: number;50 readonly detail: string | null;51 readonly path: string;52 constructor(status: number, path: string, detail: string | null, message?: string) {53 super(message ?? detail ?? `API ${status} on ${path}`);54 this.name = 'ApiError';55 this.status = status;56 this.detail = detail;57 this.path = path;58 }59 get unavailable(): boolean {60 return this.status === 503 || this.status === 0 || this.status >= 500;61 }62 get notFound(): boolean {63 return this.status === 404;64 }65}6667export interface FetchOptions {68 /** Seconds; `false` → `cache: 'no-store'`. Default 300. */69 revalidate?: number | false;70 tags?: string[];71}72export type Query = Record<string, string | number | boolean | null | undefined>;7374function qs(query?: Query): string {75 if (!query) return '';76 const p = new URLSearchParams();77 for (const [k, v] of Object.entries(query)) {78 if (v === undefined || v === null || v === '') continue;79 p.set(k, String(v));80 }81 const s = p.toString();82 return s ? `?${s}` : '';83}8485export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {86 const url = `${BASE}${path}${qs(query)}`;87 const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };88 if (opts.revalidate === false) init.cache = 'no-store';89 else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags };90 let res: Response;91 try {92 res = await fetch(url, init);93 } catch (e) {94 throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);95 }96 if (!res.ok) {97 let detail: string | null = null;98 try {99 const body = (await res.json()) as { detail?: unknown };100 detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;101 } catch {102 /* non-JSON error body */103 }104 throw new ApiError(res.status, path, detail);105 }106 return (await res.json()) as T;107}108109export async function safe<T>(p: Promise<T>): Promise<T | null> {110 try {111 return await p;112 } catch {113 return null;114 }115}116117const enc = encodeURIComponent;118119// ---------------------------------------------------------------------------------------------------------- endpoints120export const api = {121 health: () => request<Health>('/health', undefined, { revalidate: false }),122 stats: () => request<Stats>('/stats', undefined, { revalidate: 60 }),123 statsHistory: (days = 90) => request<StatsHistory>('/stats/history', { days }, { revalidate: 900 }),124125 search: (q: string, query: Query = {}) => request<SearchPayload>('/search', { q, ...query }, { revalidate: false }),126 suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, { revalidate: false }),127128 entity: (slugOrId: string) => request<EntityDetail>(`/entities/${enc(slugOrId)}`, undefined, { revalidate: 120 }),129 /** Type-mounted detail: `/models/<slug>`, `/companies/<slug>`, … (404 if the type does not match). */130 entityOfType: (typePath: string, slug: string) => request<EntityDetail>(`/${typePath}/${enc(slug)}`, undefined, { revalidate: 120 }),131 entityTimeline: (slug: string, limit = 50, before?: string) => request<{ items: ChangeEvent[] }>(`/entities/${enc(slug)}/timeline`, { limit, before }, { revalidate: 120 }),132 entityHistory: (slug: string, property?: string) => request<{ items: Claim[] }>(`/entities/${enc(slug)}/history`, { property }, { revalidate: 300 }),133 entityAsOf: (slug: string, date: string) => request<AsOfPayload>(`/entities/${enc(slug)}/asof`, { date }, { revalidate: 3600 }),134 entityGraph: (slug: string, depth = 1, limit = 80) => request<GraphPayload>(`/entities/${enc(slug)}/graph`, { depth, limit }, { revalidate: 600 }),135 entitySources: (slug: string) => request<{ items: SourceRef[] }>(`/entities/${enc(slug)}/sources`, undefined, { revalidate: 300 }),136 entityRelated: (slug: string, limit = 12) => request<{ items: EntitySummary[] }>(`/entities/${enc(slug)}/related`, { limit }, { revalidate: 600 }),137138 models: (query: Query) => request<ModelsPage>('/models', query, { revalidate: 120 }),139 companies: (query: Query) => request<CompaniesPage>('/companies', query, { revalidate: 300 }),140 papers: (query: Query) => request<Page<EntitySummary>>('/papers', query, { revalidate: 300 }),141 providers: () => request<{ items: ProviderRow[] }>('/providers', undefined, { revalidate: 300 }),142 prices: (query: Query) => request<Page<Price>>('/prices', { current: 1, ...query }, { revalidate: 300 }),143 priceHistory: (query: Query) => request<{ items: Price[] }>('/prices/history', query, { revalidate: 600 }),144 priceIndex: (days = 180) => request<PriceIndex>('/prices/index', { days }, { revalidate: 900 }),145 benchmarks: () => request<{ items: BenchmarkRow[] }>('/benchmarks', undefined, { revalidate: 300 }),146 benchmarkResults: (slug: string, query: Query = {}) => request<Page<BenchmarkResult>>(`/benchmarks/${enc(slug)}/results`, query, { revalidate: 300 }),147 benchmarkHistory: (slug: string, model?: string) => request<{ items: BenchmarkResult[] }>(`/benchmarks/${enc(slug)}/history`, { model }, { revalidate: 600 }),148 hardware: (query: Query) => request<Page<EntitySummary>>('/hardware', query, { revalidate: 600 }),149 hardwareFit: (query: Query) => request<HardwareFit>('/hardware/fit', query, { revalidate: 600 }),150151 exploreTypes: () => request<{ items: ExploreType[] }>('/explore/types', undefined, { revalidate: 300 }),152 explore: (type: string, query: Query) => request<Page<EntitySummary>>(`/explore/${enc(type)}`, query, { revalidate: 300 }),153154 changes: (query: Query) => request<Page<ChangeEvent>>('/changes', query, { revalidate: 60 }),155 changesDaily: (date?: string) => request<DailyDigest>('/changes/daily', { date }, { revalidate: 300 }),156 changesCategories: (days = 7) => request<ChangeCategories>('/changes/categories', { days }, { revalidate: 600 }),157 timeline: (query: Query) => request<TimelinePayload>('/timeline', query, { revalidate: 600 }),158 compare: (ids: string[]) => request<ComparePayload>('/compare', { ids: ids.join(',') }, { revalidate: 300 }),159 diff: (a: string, b: string, scope = 'all') => request<DiffPayload>('/diff', { a, b, scope }, { revalidate: 3600 }),160161 sources: () => request<{ items: SourceRow[] }>('/sources', undefined, { revalidate: 300 }),162 methodology: () => request<Methodology>('/methodology', undefined, { revalidate: 3600 }),163 trending: (days = 7, limit = 12) => request<{ items: TrendingRow[] }>('/trending', { days, limit }, { revalidate: 300 }),164 sitemap: (type?: string, limit = 5000, offset = 0) => request<SitemapPayload>('/sitemap', { type, limit, offset }, { revalidate: 3600 }),165};166167/** Total number of items of one type, from `/stats` (preferred) or a 1-item listing. Null when unavailable. */168export async function countOfType(type: string): Promise<number | null> {169 const s = await safe(api.stats());170 const v = s?.entities?.[type];171 if (v === undefined || v === null) return null;172 const n = Number(v);173 return Number.isFinite(n) ? n : null;174}175