SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
15.7 KB · 286 lines typescript
Raw Blame History
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  FrontierPayload,17  GraphPayload,18  OpenModelsPayload,19  ProvenanceDetail,20  PulsePayload,21  HardwareFit,22  Health,23  Methodology,24  ModelsPage,25  Page,26  Price,27  PriceIndex,28  ProviderRow,29  SearchPayload,30  SitemapPayload,31  SourceRef,32  SourceRow,33  Stats,34  StatsHistory,35  Suggestion,36  TimelinePayload,37  TrendingRow,38} from './types';3940/**41 * Typed fetch wrapper for the AI Atlas API (server components only — client code calls the same-origin `/api/v1/*`42 * rewrite through `src/lib/client-api.ts`).43 *44 * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store).45 * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages must render an46 *   "Unavailable" state rather than crash (graceful degradation). 404 → `notFound()` in the page.47 * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel.48 */49export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, '');50const BASE = `${API_URL}/api/v1`;5152export class ApiError extends Error {53  readonly status: number;54  readonly detail: string | null;55  readonly path: string;56  constructor(status: number, path: string, detail: string | null, message?: string) {57    super(message ?? detail ?? `API ${status} on ${path}`);58    this.name = 'ApiError';59    this.status = status;60    this.detail = detail;61    this.path = path;62  }63  get unavailable(): boolean {64    return this.status === 503 || this.status === 0 || this.status >= 500;65  }66  get notFound(): boolean {67    return this.status === 404;68  }69}7071export interface FetchOptions {72  /** Seconds; `false` → `cache: 'no-store'`. Default 300. */73  revalidate?: number | false;74  tags?: string[];75}76export type Query = Record<string, string | number | boolean | null | undefined>;7778function qs(query?: Query): string {79  if (!query) return '';80  const p = new URLSearchParams();81  for (const [k, v] of Object.entries(query)) {82    if (v === undefined || v === null || v === '') continue;83    p.set(k, String(v));84  }85  const s = p.toString();86  return s ? `?${s}` : '';87}8889export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {90  const url = `${BASE}${path}${qs(query)}`;91  const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };92  if (opts.revalidate === false) init.cache = 'no-store';93  else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags };94  let res: Response;95  try {96    res = await fetch(url, init);97  } catch (e) {98    throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);99  }100  if (!res.ok) {101    let detail: string | null = null;102    try {103      const body = (await res.json()) as { detail?: unknown };104      detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;105    } catch {106      /* non-JSON error body */107    }108    throw new ApiError(res.status, path, detail);109  }110  return (await res.json()) as T;111}112113export async function safe<T>(p: Promise<T>): Promise<T | null> {114  try {115    return await p;116  } catch {117    return null;118  }119}120121const enc = encodeURIComponent;122123// ---------------------------------------------------------------------------------------------------------- endpoints124export const api = {125  health: () => request<Health>('/health', undefined, { revalidate: false }),126  stats: () => request<Stats>('/stats', undefined, { revalidate: 60 }),127  statsHistory: (days = 90) => request<StatsHistory>('/stats/history', { days }, { revalidate: 900 }),128129  search: (q: string, query: Query = {}) => request<SearchPayload>('/search', { q, ...query }, { revalidate: false }),130  suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, { revalidate: false }),131132  entity: (slugOrId: string) => request<EntityDetail>(`/entities/${enc(slugOrId)}`, undefined, { revalidate: 120 }),133  /** Type-mounted detail: `/models/<slug>`, `/companies/<slug>`, … (404 if the type does not match). */134  entityOfType: (typePath: string, slug: string) => request<EntityDetail>(`/${typePath}/${enc(slug)}`, undefined, { revalidate: 120 }),135  entityTimeline: (slug: string, limit = 50, before?: string) => request<{ items: ChangeEvent[] }>(`/entities/${enc(slug)}/timeline`, { limit, before }, { revalidate: 120 }),136  entityHistory: (slug: string, property?: string) => request<{ items: Claim[] }>(`/entities/${enc(slug)}/history`, { property }, { revalidate: 300 }),137  entityAsOf: (slug: string, date: string) => request<AsOfPayload>(`/entities/${enc(slug)}/asof`, { date }, { revalidate: 3600 }),138  entityGraph: (slug: string, depth = 1, limit = 80) => request<GraphPayload>(`/entities/${enc(slug)}/graph`, { depth, limit }, { revalidate: 600 }),139  entitySources: (slug: string) => request<{ items: SourceRef[] }>(`/entities/${enc(slug)}/sources`, undefined, { revalidate: 300 }),140  entityRelated: (slug: string, limit = 12) => request<{ items: EntitySummary[] }>(`/entities/${enc(slug)}/related`, { limit }, { revalidate: 600 }),141142  models: (query: Query) => request<ModelsPage>('/models', query, { revalidate: 120 }),143  companies: (query: Query) => request<CompaniesPage>('/companies', query, { revalidate: 300 }),144  papers: (query: Query) => request<Page<EntitySummary>>('/papers', query, { revalidate: 300 }),145  providers: () => request<{ items: ProviderRow[] }>('/providers', undefined, { revalidate: 300 }),146  prices: (query: Query) => request<Page<Price>>('/prices', { current: 1, ...query }, { revalidate: 300 }),147  priceHistory: (query: Query) => request<{ items: Price[] }>('/prices/history', query, { revalidate: 600 }),148  priceIndex: (days = 180) => request<PriceIndex>('/prices/index', { days }, { revalidate: 900 }),149  benchmarks: () => request<{ items: BenchmarkRow[] }>('/benchmarks', undefined, { revalidate: 300 }),150  benchmarkResults: (slug: string, query: Query = {}) => request<Page<BenchmarkResult>>(`/benchmarks/${enc(slug)}/results`, query, { revalidate: 300 }),151  benchmarkHistory: (slug: string, model?: string) => request<{ items: BenchmarkResult[] }>(`/benchmarks/${enc(slug)}/history`, { model }, { revalidate: 600 }),152  hardware: (query: Query) => request<Page<EntitySummary>>('/hardware', query, { revalidate: 600 }),153  hardwareFit: (query: Query) => request<HardwareFit>('/hardware/fit', query, { revalidate: 600 }),154155  exploreTypes: () => request<{ items: ExploreType[] }>('/explore/types', undefined, { revalidate: 300 }),156  explore: (type: string, query: Query) => request<Page<EntitySummary>>(`/explore/${enc(type)}`, query, { revalidate: 300 }),157158  changes: (query: Query) => request<Page<ChangeEvent>>('/changes', query, { revalidate: 60 }),159  changesDaily: (date?: string) => request<DailyDigest>('/changes/daily', { date }, { revalidate: 300 }),160  changesCategories: (days = 7) => request<ChangeCategories>('/changes/categories', { days }, { revalidate: 600 }),161  timeline: (query: Query) => request<TimelinePayload>('/timeline', query, { revalidate: 600 }),162  compare: (ids: string[]) => request<ComparePayload>('/compare', { ids: ids.join(',') }, { revalidate: 300 }),163  diff: (a: string, b: string, scope = 'all') => request<DiffPayload>('/diff', { a, b, scope }, { revalidate: 3600 }),164165  sources: () => request<{ items: SourceRow[] }>('/sources', undefined, { revalidate: 300 }),166  methodology: () => request<Methodology>('/methodology', undefined, { revalidate: 3600 }),167  trending: (days = 7, limit = 12) => request<{ items: TrendingRow[] }>('/trending', { days, limit }, { revalidate: 300 }),168  sitemap: (type?: string, limit = 5000, offset = 0) => request<SitemapPayload>('/sitemap', { type, limit, offset }, { revalidate: 3600 }),169170  // ---- 1.1 additions (endpoints may 404 until the API stream lands — always wrap in `safe()` and provide a fallback)171  frontier: () => request<FrontierPayload>('/frontier', undefined, { revalidate: 300 }),172  pulse: () => request<PulsePayload>('/pulse', undefined, { revalidate: 60 }),173  open: (days = 30, limit = 12) => request<OpenModelsPayload>('/open', { days, limit }, { revalidate: 300 }),174  provenance: (slug: string, property: string) => request<ProvenanceDetail>(`/entities/${enc(slug)}/provenance/${enc(property)}`, undefined, { revalidate: 300 }),175  claim: (id: string) => request<Claim & { entity?: EntitySummary }>(`/claims/${enc(id)}`, undefined, { revalidate: 3600 }),176};177178/** Total number of items of one type, from `/stats` (preferred) or a 1-item listing. Null when unavailable. */179export async function countOfType(type: string): Promise<number | null> {180  const s = await safe(api.stats());181  const v = s?.entities?.[type];182  if (v === undefined || v === null) return null;183  const n = Number(v);184  return Number.isFinite(n) ? n : null;185}186187// ---- D1 (models/benchmarks/compare) ----188import type {189  BenchmarkDetail,190  BenchmarkFrontierPayload,191  BenchmarksPayload,192  ComparePayload11,193  FamiliesPage,194  FamilyDetail,195  LeaderboardPayload,196  LicenseDetail,197  LicensesPayload,198  MatrixPayload,199  Methodology11,200  ModelDetail,201  ModelDiffPayload,202  ModelsPage11,203  ParetoPayload,204} from './types';205206/** 1.1 endpoints used by the models · benchmarks · compare · families · licences pages (docs/API.md §1.1). */207export const apiD1 = {208  /** `/models` with the 1.1 facets (families, canonical licences, trust) and `include=artifacts`. */209  models: (query: Query) => request<ModelsPage11>('/models', query, { revalidate: 120 }),210  /** `/models/<slug>` — accepts models AND artifacts; folded variants come back as the canonical model with `redirected_from`. */211  model: (slug: string) => request<ModelDetail>(`/models/${enc(slug)}`, undefined, { revalidate: 120 }),212  modelDiff: (a: string, b: string) => request<ModelDiffPayload>(`/models/${enc(a)}/diff/${enc(b)}`, undefined, { revalidate: 300 }),213  compare: (ids: string[], opts: { diff_only?: boolean; mode?: string } = {}) => request<ComparePayload11>('/compare', { ids: ids.join(','), diff_only: opts.diff_only ? 1 : undefined, mode: opts.mode }, { revalidate: 300 }),214  benchmarks: (category?: string) => request<BenchmarksPayload>('/benchmarks', { category }, { revalidate: 300 }),215  benchmark: (slug: string) => request<BenchmarkDetail>(`/benchmarks/${enc(slug)}`, undefined, { revalidate: 120 }),216  leaderboard: (slug: string, query: Query = {}) => request<LeaderboardPayload>(`/benchmarks/${enc(slug)}/leaderboard`, query, { revalidate: 300 }),217  frontier: (slug: string, query: Query = {}) => request<BenchmarkFrontierPayload>(`/benchmarks/${enc(slug)}/frontier`, query, { revalidate: 600 }),218  matrix: (query: Query = {}) => request<MatrixPayload>('/benchmarks/matrix', query, { revalidate: 600 }),219  pareto: (query: Query) => request<ParetoPayload>('/pareto', query, { revalidate: 600 }),220  families: (query: Query = {}) => request<FamiliesPage>('/families', query, { revalidate: 600 }),221  family: (slug: string, limit = 200) => request<FamilyDetail>(`/families/${enc(slug)}`, { limit }, { revalidate: 600 }),222  licenses: () => request<LicensesPayload>('/licenses', undefined, { revalidate: 3600 }),223  license: (key: string, limit = 100, offset = 0) => request<LicenseDetail>(`/licenses/${enc(key)}`, { limit, offset }, { revalidate: 3600 }),224  methodology: () => request<Methodology11>('/methodology', undefined, { revalidate: 3600 }),225};226// ---- /D1 ----227228// ---- D2 (intelligence) ----229import type {230  CostContextPayload,231  CostPayload,232  DeploymentsPage,233  FinderPayload,234  FrontierIntel,235  HardwarePage,236  HardwareSlugFit,237  MethodologyIntel,238  OpenPayload,239  PriceIndexIntel,240  ProviderIntelRow,241  PulseIntel,242  RunLocallyPayload,243} from './types';244245/** 1.1 intelligence routes, typed for the D2 pages (frontier · prices · calculator · run-locally · find-a-model · open · pulse · providers · hardware). */246export const intel = {247  frontier: (limit = 12) => request<FrontierIntel>('/frontier', { limit }, { revalidate: 300 }),248  /** `/pareto` is typed in the D1 block: `apiD1.pareto(query)`. */249  priceIndex: (days = 180) => request<PriceIndexIntel>('/prices/index', { days }, { revalidate: 900 }),250  prices: (query: Query) => request<Page<Price> & { methodology?: string }>('/prices', { current: 1, ...query }, { revalidate: 300 }),251  providers: () => request<{ items: ProviderIntelRow[]; note?: string }>('/providers', undefined, { revalidate: 300 }),252  deployments: (query: Query) => request<DeploymentsPage>('/deployments', query, { revalidate: 300 }),253  cost: (query: Query) => request<CostPayload>('/cost', query, { revalidate: 300 }),254  costContext: (query: Query) => request<CostContextPayload>('/cost/context', query, { revalidate: 300 }),255  runLocally: (query: Query) => request<RunLocallyPayload>('/run-locally', query, { revalidate: 600 }),256  hardware: (query: Query) => request<HardwarePage>('/hardware', { facets: 1, ...query }, { revalidate: 600 }),257  hardwareSlugFit: (slug: string, query: Query = {}) => request<HardwareSlugFit>(`/hardware/${enc(slug)}/fit`, query, { revalidate: 600 }),258  findAModel: (query: Query) => request<FinderPayload>('/find-a-model', query, { revalidate: 300 }),259  open: (query: Query) => request<OpenPayload>('/open', query, { revalidate: 300 }),260  pulse: (days = 7) => request<PulseIntel>('/pulse', { days }, { revalidate: 120 }),261  methodology: () => request<MethodologyIntel>('/methodology', undefined, { revalidate: 3600 }),262};263// ---- /D2 ----264265// ---- D3 (temporal/graph/admin) ----266import type { ChangesPage, ClaimDetail, DailyDigest2, DiffPayload11, EntityClaimsPayload, GraphExploreMode, GraphExplorePayload, MethodologyD3, SourcesPayload, TimeMachinePayload, TimelinePayload11, TrendingPayload, SearchPayload2 } from './types';267268export const apiD3 = {269  /** Typed neighbourhood explorer (7 modes). Never more than `limit` nodes; `truncated` says when the API cut. */270  graphExplore: (node: string, mode: GraphExploreMode, depth: 1 | 2 = 1, limit = 150) => request<GraphExplorePayload>('/graph/explore', { node, mode, depth, limit }, { revalidate: 600 }),271  timeMachine: (date: string, scope: string, limit = 50) => request<TimeMachinePayload>('/time-machine', { date, scope, limit }, { revalidate: 3600 }),272  diff: (a: string, b: string, scope = 'all', limit = 200, includeBackfill = false) => request<DiffPayload11>('/diff', { a, b, scope, limit, include_backfill: includeBackfill ? 1 : undefined }, { revalidate: 1800 }),273  changesDaily: (date?: string, perSection = 30, includeBackfill = false) => request<DailyDigest2>('/changes/daily', { date, per_section: perSection, include_backfill: includeBackfill ? 1 : undefined }, { revalidate: 300 }),274  changes: (query: Query) => request<ChangesPage>('/changes', query, { revalidate: 60 }),275  timeline: (query: Query) => request<TimelinePayload11>('/timeline', query, { revalidate: 600 }),276  search: (q: string, query: Query = {}) => request<SearchPayload2>('/search', { q, ...query }, { revalidate: false }),277  claim: (id: string) => request<ClaimDetail>(`/claims/${enc(id)}`, undefined, { revalidate: 600 }),278  entityClaims: (slug: string, query: Query = {}) => request<EntityClaimsPayload>(`/entities/${enc(slug)}/claims`, query, { revalidate: 300 }),279  methodology: () => request<MethodologyD3>('/methodology', undefined, { revalidate: 3600 }),280  sources: () => request<SourcesPayload>('/sources', undefined, { revalidate: 300 }),281  trending: (kind: string, query: Query = {}) => request<TrendingPayload>('/trending', { kind, ...query }, { revalidate: 300 }),282  /** Generic entity timeline with the 1.1 flags (`include_backfill`, `date_field`). */283  entityTimeline: (slug: string, query: Query = {}) => request<ChangesPage>(`/entities/${enc(slug)}/timeline`, query, { revalidate: 120 }),284};285// ---- /D3 ----286