import 'server-only'; import type { AsOfPayload, BenchmarkResult, BenchmarkRow, ChangeCategories, ChangeEvent, Claim, CompaniesPage, ComparePayload, DailyDigest, DiffPayload, EntityDetail, EntitySummary, ExploreType, FrontierPayload, GraphPayload, OpenModelsPayload, ProvenanceDetail, PulsePayload, HardwareFit, Health, Methodology, ModelsPage, Page, Price, PriceIndex, ProviderRow, SearchPayload, SitemapPayload, SourceRef, SourceRow, Stats, StatsHistory, Suggestion, TimelinePayload, TrendingRow, } from './types'; /** * Typed fetch wrapper for the AI Atlas API (server components only — client code calls the same-origin `/api/v1/*` * rewrite through `src/lib/client-api.ts`). * * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store). * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages must render an * "Unavailable" state rather than crash (graceful degradation). 404 → `notFound()` in the page. * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel. */ export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, ''); const BASE = `${API_URL}/api/v1`; export class ApiError extends Error { readonly status: number; readonly detail: string | null; readonly path: string; constructor(status: number, path: string, detail: string | null, message?: string) { super(message ?? detail ?? `API ${status} on ${path}`); this.name = 'ApiError'; this.status = status; this.detail = detail; this.path = path; } get unavailable(): boolean { return this.status === 503 || this.status === 0 || this.status >= 500; } get notFound(): boolean { return this.status === 404; } } export interface FetchOptions { /** Seconds; `false` → `cache: 'no-store'`. Default 300. */ revalidate?: number | false; tags?: string[]; } export type Query = Record; function qs(query?: Query): string { if (!query) return ''; const p = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v === undefined || v === null || v === '') continue; p.set(k, String(v)); } const s = p.toString(); return s ? `?${s}` : ''; } export async function request(path: string, query?: Query, opts: FetchOptions = {}): Promise { const url = `${BASE}${path}${qs(query)}`; const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } }; if (opts.revalidate === false) init.cache = 'no-store'; else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags }; let res: Response; try { res = await fetch(url, init); } catch (e) { throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`); } if (!res.ok) { let detail: string | null = null; try { const body = (await res.json()) as { detail?: unknown }; detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null; } catch { /* non-JSON error body */ } throw new ApiError(res.status, path, detail); } return (await res.json()) as T; } export async function safe(p: Promise): Promise { try { return await p; } catch { return null; } } const enc = encodeURIComponent; // ---------------------------------------------------------------------------------------------------------- endpoints export const api = { health: () => request('/health', undefined, { revalidate: false }), stats: () => request('/stats', undefined, { revalidate: 60 }), statsHistory: (days = 90) => request('/stats/history', { days }, { revalidate: 900 }), search: (q: string, query: Query = {}) => request('/search', { q, ...query }, { revalidate: false }), suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, { revalidate: false }), entity: (slugOrId: string) => request(`/entities/${enc(slugOrId)}`, undefined, { revalidate: 120 }), /** Type-mounted detail: `/models/`, `/companies/`, … (404 if the type does not match). */ entityOfType: (typePath: string, slug: string) => request(`/${typePath}/${enc(slug)}`, undefined, { revalidate: 120 }), entityTimeline: (slug: string, limit = 50, before?: string) => request<{ items: ChangeEvent[] }>(`/entities/${enc(slug)}/timeline`, { limit, before }, { revalidate: 120 }), entityHistory: (slug: string, property?: string) => request<{ items: Claim[] }>(`/entities/${enc(slug)}/history`, { property }, { revalidate: 300 }), entityAsOf: (slug: string, date: string) => request(`/entities/${enc(slug)}/asof`, { date }, { revalidate: 3600 }), entityGraph: (slug: string, depth = 1, limit = 80) => request(`/entities/${enc(slug)}/graph`, { depth, limit }, { revalidate: 600 }), entitySources: (slug: string) => request<{ items: SourceRef[] }>(`/entities/${enc(slug)}/sources`, undefined, { revalidate: 300 }), entityRelated: (slug: string, limit = 12) => request<{ items: EntitySummary[] }>(`/entities/${enc(slug)}/related`, { limit }, { revalidate: 600 }), models: (query: Query) => request('/models', query, { revalidate: 120 }), companies: (query: Query) => request('/companies', query, { revalidate: 300 }), papers: (query: Query) => request>('/papers', query, { revalidate: 300 }), providers: () => request<{ items: ProviderRow[] }>('/providers', undefined, { revalidate: 300 }), prices: (query: Query) => request>('/prices', { current: 1, ...query }, { revalidate: 300 }), priceHistory: (query: Query) => request<{ items: Price[] }>('/prices/history', query, { revalidate: 600 }), priceIndex: (days = 180) => request('/prices/index', { days }, { revalidate: 900 }), benchmarks: () => request<{ items: BenchmarkRow[] }>('/benchmarks', undefined, { revalidate: 300 }), benchmarkResults: (slug: string, query: Query = {}) => request>(`/benchmarks/${enc(slug)}/results`, query, { revalidate: 300 }), benchmarkHistory: (slug: string, model?: string) => request<{ items: BenchmarkResult[] }>(`/benchmarks/${enc(slug)}/history`, { model }, { revalidate: 600 }), hardware: (query: Query) => request>('/hardware', query, { revalidate: 600 }), hardwareFit: (query: Query) => request('/hardware/fit', query, { revalidate: 600 }), exploreTypes: () => request<{ items: ExploreType[] }>('/explore/types', undefined, { revalidate: 300 }), explore: (type: string, query: Query) => request>(`/explore/${enc(type)}`, query, { revalidate: 300 }), changes: (query: Query) => request>('/changes', query, { revalidate: 60 }), changesDaily: (date?: string) => request('/changes/daily', { date }, { revalidate: 300 }), changesCategories: (days = 7) => request('/changes/categories', { days }, { revalidate: 600 }), timeline: (query: Query) => request('/timeline', query, { revalidate: 600 }), compare: (ids: string[]) => request('/compare', { ids: ids.join(',') }, { revalidate: 300 }), diff: (a: string, b: string, scope = 'all') => request('/diff', { a, b, scope }, { revalidate: 3600 }), sources: () => request<{ items: SourceRow[] }>('/sources', undefined, { revalidate: 300 }), methodology: () => request('/methodology', undefined, { revalidate: 3600 }), trending: (days = 7, limit = 12) => request<{ items: TrendingRow[] }>('/trending', { days, limit }, { revalidate: 300 }), sitemap: (type?: string, limit = 5000, offset = 0) => request('/sitemap', { type, limit, offset }, { revalidate: 3600 }), // ---- 1.1 additions (endpoints may 404 until the API stream lands — always wrap in `safe()` and provide a fallback) frontier: () => request('/frontier', undefined, { revalidate: 300 }), pulse: () => request('/pulse', undefined, { revalidate: 60 }), open: (days = 30, limit = 12) => request('/open', { days, limit }, { revalidate: 300 }), provenance: (slug: string, property: string) => request(`/entities/${enc(slug)}/provenance/${enc(property)}`, undefined, { revalidate: 300 }), claim: (id: string) => request(`/claims/${enc(id)}`, undefined, { revalidate: 3600 }), }; /** Total number of items of one type, from `/stats` (preferred) or a 1-item listing. Null when unavailable. */ export async function countOfType(type: string): Promise { const s = await safe(api.stats()); const v = s?.entities?.[type]; if (v === undefined || v === null) return null; const n = Number(v); return Number.isFinite(n) ? n : null; } // ---- D1 (models/benchmarks/compare) ---- import type { BenchmarkDetail, BenchmarkFrontierPayload, BenchmarksPayload, ComparePayload11, FamiliesPage, FamilyDetail, LeaderboardPayload, LicenseDetail, LicensesPayload, MatrixPayload, Methodology11, ModelDetail, ModelDiffPayload, ModelsPage11, ParetoPayload, } from './types'; /** 1.1 endpoints used by the models · benchmarks · compare · families · licences pages (docs/API.md §1.1). */ export const apiD1 = { /** `/models` with the 1.1 facets (families, canonical licences, trust) and `include=artifacts`. */ models: (query: Query) => request('/models', query, { revalidate: 120 }), /** `/models/` — accepts models AND artifacts; folded variants come back as the canonical model with `redirected_from`. */ model: (slug: string) => request(`/models/${enc(slug)}`, undefined, { revalidate: 120 }), modelDiff: (a: string, b: string) => request(`/models/${enc(a)}/diff/${enc(b)}`, undefined, { revalidate: 300 }), compare: (ids: string[], opts: { diff_only?: boolean; mode?: string } = {}) => request('/compare', { ids: ids.join(','), diff_only: opts.diff_only ? 1 : undefined, mode: opts.mode }, { revalidate: 300 }), benchmarks: (category?: string) => request('/benchmarks', { category }, { revalidate: 300 }), benchmark: (slug: string) => request(`/benchmarks/${enc(slug)}`, undefined, { revalidate: 120 }), leaderboard: (slug: string, query: Query = {}) => request(`/benchmarks/${enc(slug)}/leaderboard`, query, { revalidate: 300 }), frontier: (slug: string, query: Query = {}) => request(`/benchmarks/${enc(slug)}/frontier`, query, { revalidate: 600 }), matrix: (query: Query = {}) => request('/benchmarks/matrix', query, { revalidate: 600 }), pareto: (query: Query) => request('/pareto', query, { revalidate: 600 }), families: (query: Query = {}) => request('/families', query, { revalidate: 600 }), family: (slug: string, limit = 200) => request(`/families/${enc(slug)}`, { limit }, { revalidate: 600 }), licenses: () => request('/licenses', undefined, { revalidate: 3600 }), license: (key: string, limit = 100, offset = 0) => request(`/licenses/${enc(key)}`, { limit, offset }, { revalidate: 3600 }), methodology: () => request('/methodology', undefined, { revalidate: 3600 }), }; // ---- /D1 ---- // ---- D2 (intelligence) ---- import type { CostContextPayload, CostPayload, DeploymentsPage, FinderPayload, FrontierIntel, HardwarePage, HardwareSlugFit, MethodologyIntel, OpenPayload, PriceIndexIntel, ProviderIntelRow, PulseIntel, RunLocallyPayload, } from './types'; /** 1.1 intelligence routes, typed for the D2 pages (frontier · prices · calculator · run-locally · find-a-model · open · pulse · providers · hardware). */ export const intel = { frontier: (limit = 12) => request('/frontier', { limit }, { revalidate: 300 }), /** `/pareto` is typed in the D1 block: `apiD1.pareto(query)`. */ priceIndex: (days = 180) => request('/prices/index', { days }, { revalidate: 900 }), prices: (query: Query) => request & { methodology?: string }>('/prices', { current: 1, ...query }, { revalidate: 300 }), providers: () => request<{ items: ProviderIntelRow[]; note?: string }>('/providers', undefined, { revalidate: 300 }), deployments: (query: Query) => request('/deployments', query, { revalidate: 300 }), cost: (query: Query) => request('/cost', query, { revalidate: 300 }), costContext: (query: Query) => request('/cost/context', query, { revalidate: 300 }), runLocally: (query: Query) => request('/run-locally', query, { revalidate: 600 }), hardware: (query: Query) => request('/hardware', { facets: 1, ...query }, { revalidate: 600 }), hardwareSlugFit: (slug: string, query: Query = {}) => request(`/hardware/${enc(slug)}/fit`, query, { revalidate: 600 }), findAModel: (query: Query) => request('/find-a-model', query, { revalidate: 300 }), open: (query: Query) => request('/open', query, { revalidate: 300 }), pulse: (days = 7) => request('/pulse', { days }, { revalidate: 120 }), methodology: () => request('/methodology', undefined, { revalidate: 3600 }), }; // ---- /D2 ---- // ---- D3 (temporal/graph/admin) ---- import type { ChangesPage, ClaimDetail, DailyDigest2, DiffPayload11, EntityClaimsPayload, GraphExploreMode, GraphExplorePayload, MethodologyD3, SourcesPayload, TimeMachinePayload, TimelinePayload11, TrendingPayload, SearchPayload2 } from './types'; export const apiD3 = { /** Typed neighbourhood explorer (7 modes). Never more than `limit` nodes; `truncated` says when the API cut. */ graphExplore: (node: string, mode: GraphExploreMode, depth: 1 | 2 = 1, limit = 150) => request('/graph/explore', { node, mode, depth, limit }, { revalidate: 600 }), timeMachine: (date: string, scope: string, limit = 50) => request('/time-machine', { date, scope, limit }, { revalidate: 3600 }), diff: (a: string, b: string, scope = 'all', limit = 200, includeBackfill = false) => request('/diff', { a, b, scope, limit, include_backfill: includeBackfill ? 1 : undefined }, { revalidate: 1800 }), changesDaily: (date?: string, perSection = 30, includeBackfill = false) => request('/changes/daily', { date, per_section: perSection, include_backfill: includeBackfill ? 1 : undefined }, { revalidate: 300 }), changes: (query: Query) => request('/changes', query, { revalidate: 60 }), timeline: (query: Query) => request('/timeline', query, { revalidate: 600 }), search: (q: string, query: Query = {}) => request('/search', { q, ...query }, { revalidate: false }), claim: (id: string) => request(`/claims/${enc(id)}`, undefined, { revalidate: 600 }), entityClaims: (slug: string, query: Query = {}) => request(`/entities/${enc(slug)}/claims`, query, { revalidate: 300 }), methodology: () => request('/methodology', undefined, { revalidate: 3600 }), sources: () => request('/sources', undefined, { revalidate: 300 }), trending: (kind: string, query: Query = {}) => request('/trending', { kind, ...query }, { revalidate: 300 }), /** Generic entity timeline with the 1.1 flags (`include_backfill`, `date_field`). */ entityTimeline: (slug: string, query: Query = {}) => request(`/entities/${enc(slug)}/timeline`, query, { revalidate: 120 }), }; // ---- /D3 ----