SPB Git

spb/ai-atlas

Public
8.1 KB · 175 lines typescript
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
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme