# AI Atlas API — contract (API 1.1, mounted at `/api/v1`) Base: `/api/v1` (FastAPI, `src/aiatlas/api`). JSON, UTF-8, ISO-8601 UTC timestamps, numbers as numbers (Postgres aggregates may arrive as strings — the web layer normalises). Public routes are cached in Redis (`aia:api:*`, 60–600 s), carry a weak `ETag` + `Cache-Control: public, max-age=60, stale-while-revalidate=300` and answer `304` to a matching `If-None-Match`; search is rate-limited per IP. Every response carries `x-api-version: 1.1`. Admin routes require header `x-aia-admin-token: ` and are rate-limited **before** the token check (240/min per IP; 10 failed authentications per minute per IP → 429). OpenAPI at `/api/v1/docs`. Errors: `{"detail": "..."}` with 400/404/409/422/429/501/503. Never expose credentials, internal hostnames, raw archive content or `raw_path`s. **1.1 is additive over v1.** No v1 route, field or type was removed or retyped (see "Deprecations": none). New fields and routes are listed below; three defaults changed semantics (see "Semantics changes") and each has a parameter that restores the v1 behaviour. Every number that is derived or estimated comes with a `methodology`, `note`, `definition` or `estimated: true` string next to it; when data is missing the value is `null`/empty with a note — nothing is fabricated. ## Changelog | version | date | changes | |---|---|---| | **1.1** | 2026-09-12 | Canonical model universe (`/models` excludes artifacts and folded variants; `include=artifacts`), model detail blocks (family, artifacts, deployments, identity, licence, openness, version_history, grouped benchmarks, `redirected_from`), backfill-aware feeds keyed on `occurred_at`, "Today in AI 2.0", comparability groups + one-row-per-model leaderboards, `/benchmarks/matrix`, `/benchmarks/{slug}/frontier`, grouped `/compare` + `diff_only` + `/models/{a}/diff/{b}`, `Deployment` shape + `/deployments` + `/cost`, AI Price Index, provider aggregates, `/frontier`, `/pareto`, `/pulse`, `/open`, `/find-a-model`, `/run-locally`, `/hardware/{slug}/fit`, `/families`, `/graph/explore`, `/time-machine`, `/diff` sections, search compiler v2 (`compiled`), `/claims/{id}`, `/entities/{slug}/claims`, `/entities/{slug}/provenance/{property}`, `/licenses`, richer `/methodology`, `/trending?kind=`, `/stats` counter definitions, admin workbenches (quality, entity-resolution, anomalies, extractions, quarantine, audit, rollback), ETag/304, admin rate limit before auth, failed-auth limiter, trust-proxy, `set_limit` GUC leak fixed, detail fan-out ≤ 4 connections. | | 1.0 | 2026-09-11 | Initial contract (table "v1 routes" below). | ## Shared shapes ```ts type Num = number | string | null type Org = { id: string; slug: string; name: string } | null type EntitySummary = { id: string; entity_type: string; slug: string; name: string; description: string | null; status: string; organization: Org; attributes: Record; quality: { score?: number; completeness?: number; primary_source_ratio?: number; freshness?: number; source_count?: number; conflicts?: number }; counts: { relations?: number; events?: number; claims?: number }; first_seen_at: string; last_seen_at: string; updated_at: string } type Page = { items: T[]; total: number; limit: number; offset: number } type Provenance = Record type ChangeEvent = { id: string; event_type: string; category: string; property: string | null; old_value: unknown; new_value: unknown; summary: string; importance: 0 | 1 | 2 | 3; observed_at: string; effective_at: string | null; source_url: string | null; connector_name: string | null; entity: EntitySummary | null; meta: Record; occurred_at?: string; is_backfill?: boolean; group_key?: string | null } // 1.1 (feeds) type Price = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; input_per_mtok: Num; output_per_mtok: Num; cached_input_per_mtok: Num; cache_write_per_mtok: Num; batch_input_per_mtok: Num; batch_output_per_mtok: Num; per_image: Num; currency: string; context_length: Num; max_output_tokens: Num; features: Record; observed_at: string; valid_from: string; valid_to: string | null; source_url: string | null; tier: number } type BenchmarkResult = { id: string; model: EntitySummary; benchmark: EntitySummary; score: number; metric: string | null; unit: string | null; higher_is_better: boolean; config: Record; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; confidence: string; valid_to: string | null; config_key?: string | null; trust_level?: string | null } // 1.1 adds config_key/trust_level where returned type Claim = { id: string; property: string; value: unknown; unit: string | null; tier: number; confidence: string; status: string; extractor: string; observed_at: string; effective_at: string | null; valid_from: string; valid_to: string | null; source_url: string | null; source_name: string | null } type SourceRef = { source_id: string | null; source_name: string | null; domain: string | null; tier: number | null; url: string; doc_type: string; last_observed_at: string | null; snapshots: number } // ---- 1.1 shapes type Deployment = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; context_length: Num; max_output_tokens: Num; prices: { input: Num; cached_input: Num; cache_write: Num; output: Num; batch_input: Num; batch_output: Num; per_image: Num; per_request: Num; currency: string; unit: "USD per 1M tokens"; native_units: Record }; // provider-specific priced features kept verbatim (flex_*, priority_*, *_per_1k_requests…) features: Record; status: "active" | "delisted"; observed_at: string; valid_from: string; valid_to: string | null; source_url: string | null; tier: number } type Group = { metric: string; config_key: string; label: string; n: number; model_count: number; config: Record; higher_is_better: boolean; trust_mix: Record } type LeaderboardRow = { rank: number; model: ModelRef; score: number; metric: string; unit: string | null; higher_is_better: boolean; delta_rank: number | null; previous_rank: number | null; trust_level: string; trust_label: string; config: Record; config_key: string; comparability: "comparable" | "partially-comparable" | "not-comparable"; comparability_reasons: string[]; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; result_id: string; n_rows: number } type ModelRef = { id: string; slug: string; name: string; entity_type: string; organization: Org; attributes: Record } // openness/params/context/release/modalities/license/family type LicenseInfo = { key: string; label: string; category: string; spdx: string | null; url: string | null; commercial_use: boolean | null; redistribution: boolean | null; derivatives: boolean | null; hosting_restrictions: boolean | null; attribution: boolean | null; acceptable_use: boolean | null; osi_approved: boolean; weights_downloadable: boolean } type Fit = { quantization: string; estimated: true; fits: boolean; estimated_memory_gb: number; headroom_gb: number; parameter_count?: number; breakdown: { weights_gb: number; weights_source: "observed" | "estimated"; overhead_gb: number; kv_cache_gb: number; kv_cache_method: "architecture" | "heuristic"; reserved_gb: number; context: number; batch: number }; device: { memory_gb: number; gpu_count: number; total_memory_gb: number }; note: string; multi_gpu_note?: string } ``` ## v1 routes (unchanged unless noted "1.1:") | route | returns | |---|---| | `GET /health` | `{ status: "ok"|"degraded", version, api_version: "1.1", db: bool, redis: bool, llm: { available, reachable? }, time }` | | `GET /stats` | `{ entities: Record, entities_total, sources, connectors, connectors_enabled, documents, snapshots, claims, claims_current, relations, change_events, change_events_24h, change_events_7d, benchmark_results, prices_current, prices_total, review_pending, llm_jobs, llm_tokens, last_snapshot_at, last_event_at, first_entity_at, archive: {…}, computed_at }` — **1.1:** `entities.model` = canonical models; `entities.artifact`, `entities.model_family` (zero-filled); adds `organizations_total`, `artifacts`, `model_families`, `change_events_live_24h`, `change_events_live_7d`, `definitions: Record` ("How counted"). Cached 60 s. | | `GET /stats/history?days=90` | `{ items: { day: string; counts: object }[] }` | | `GET /search?q=&type=&limit=30&offset=0` | `{ query: CompiledQuery, items: (EntitySummary & { rank: number })[], total: number }` — **1.1:** compiler v2 (below) | | `GET /search/suggest?q=` | `{ items: { id, entity_type, slug, name, organization_name }[] }` (≤ 8, prefix, fast) | | `GET /entities/{slug_or_id}` | `EntityDetail` (below). Also mounted as `/models/{slug}` (**1.1:** accepts `model` AND `artifact`), `/companies/{slug}`, `/papers/{slug}`, `/providers/{slug}`, `/benchmarks/{slug}` (**1.1:** slug, alias or id), `/hardware/{slug}`, `/frameworks/{slug}`, `/datasets/{slug}`, `/tools/{slug}` (404 if the type doesn't match) | | `GET /entities/{slug}/timeline?limit=50&before=&include_backfill=0&date_field=occurred` | `{ items: ChangeEvent[], next_before, date_field, include_backfill }` — **1.1:** defaults `is_backfill=false`, ordered by `occurred_at`; cursor = `occurred_at` (or `observed_at` with `date_field=observed`) | | `GET /entities/{slug}/history?property=` | `{ items: Claim[] }` — full claim history (all statuses), newest first | | `GET /entities/{slug}/asof?date=YYYY-MM-DD` | `{ existed: bool, first_seen_at, date, attributes, claims: Claim[] }` | | `GET /entities/{slug}/graph?depth=1&limit=80` | `{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] }` | | `GET /entities/{slug}/sources` | `{ items: SourceRef[] }` | | `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family (`family_id` or label) / shared relations) | | `GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=&order=&limit=&offset=&facets=1` | `Page & { universe }` (`best_price` = cheapest current offer by output price, one lateral join; summary attributes gain `license_key` derived on read, `reasoning`, `tool_calling`) — **1.1:** default universe = `entity_type='model' and merged_into is null`; `include=artifacts`; `family=` matches `model_family` slug/id/name via `entities.family_id` (falls back to `attributes.family`); `license=` matches the canonical key (`attributes.license_key`) or any raw label the ontology maps to it; `reasoning=0|1`, `trust=high,medium,low` (identity_confidence), `sort=cheapest`. Facets add `families` (`{value,label,canonical,count}`), canonical `licenses` (`{value,label,category,count,raw_labels,raw?}`), `trust`, `definitions`. | | `GET /companies?q=&country=&kind=&sort=&limit=&offset=&facets=1` | `Page` — covers `company|organization|lab|university`; `total` = `/stats.organizations_total` | | `GET /papers?…` | `Page` | | `GET /providers` | `{ items: (EntitySummary & { model_count, price_count, min_input_per_mtok, min_output_per_mtok, input_price_distribution: {min,p25,median,p75,max,n}|null, output_price_distribution, models_added_30d, models_removed_30d, price_changes_30d, organizations_covered, features_supported: string[], feature_keys: string[] })[], note }` — **1.1** aggregates | | `GET /prices?model=&provider=&sort=&limit=&offset=¤t=1&family=&org=&modality=` | `Page` — **1.1:** `family`, `org`, `modality` filters; `sort=cheapest_frontier` (frontier models only, cheapest output first, + `methodology`) | | `GET /prices/history?model=&provider=` | `{ items: Price[] }` | | `GET /prices/index?days=180` | **AI Price Index (1.1)** `{ days, series: { day, median_input, median_output, median_frontier_output, median_frontier_input, median_open_output, median_embedding_input, min_input, max_input, min_frontier_output, models, offers, sample: { models, offers, frontier_models, frontier_offers, open_models, embedding_models } }[], movers: ChangeEvent[], cheapest_frontier: { model, provider, output, input, context_length, price_id }|null, cheapest_frontier_1m_context, distribution: { metric, unit, buckets: { from, to, label, offers }[], offers }, new_listings_30d: { count, items: Deployment[], definition }, delistings_30d: { count, items: Deployment[], definition }, price_changes_30d: { count, items: (ChangeEvent & { percent_change, price_delta, provider })[], definition }, frontier: { composition, methodology }, methodology, note }` — v1 keys kept; the three 30-day counters became objects (read `.count`), items capped at 50 | | `GET /benchmarks?category=` | `{ items: { id, entity_type, slug, name, category, family, variant, metric, unit, direction, attributes, result_count, model_count, leader: LeaderboardRow|null, second, top: {model, score}|null, primary_group: Group|null, groups: Group[], trust_mix, trust_labels }[], total, note }` — **1.1:** `result_count` = current rows, `model_count` = distinct canonical models, `leader` from the primary metric group | | `GET /benchmarks/{slug}` | `EntityDetail` + `family, variant, metric, direction, category, groups, primary_group, result_count, model_count, leaderboard (top 25), trust_mix` | | `GET /benchmarks/{slug}/results?limit=&offset=&config=&history=&metric=&config_key=` | `Page` — v1 behaviour (one row per result) + `metric` / `config_key` filters | | `GET /benchmarks/{slug}/history?model=` | `{ benchmark, items: BenchmarkResult[] }` | | `GET /hardware?…` | `Page` + facets | | `GET /hardware/fit?memory_gb=&quant=&context=&limit=&openness=` | v1 estimate (unchanged) | | `GET /explore/types` · `GET /explore/{type}` | unchanged (`artifact`, `model_family` appear as types once present); **1.1:** framework/library/runtime/tool items expose `kind` (canonical framework kind, normalised on read, `kind_raw` when the source label differed) and dataset summaries surface `publisher, size, access, hf_repo, modality, license_key…` when recorded | | `GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&entity=&limit=50&before=&offset=&include_documents=0&include_backfill=0&date_field=occurred` | `Page & { next_before, date_field, include_backfill }` — **1.1:** defaults `is_backfill=false`, `occurred_at`; cursor/filters follow `date_field` | | `GET /changes/daily?date=&per_section=30&include_backfill=0&date_field=occurred` | `{ date, counts, total, sections (v1 by category), today: { key, label, items: (ChangeEvent & { sources: n, documents: string[], grouped_events, event_ids? })[], total }[], new_models, labels, backfill_excluded, previous_day, next_day, note }` — **Today in AI 2.0** sections: `MAJOR_RELEASES, OPEN_WEIGHT_RELEASES, PRICE_MOVES, BENCHMARK_MOVES, MODEL_CHANGES, RESEARCH, DEPRECATIONS, PROVIDER_CHANGES, HARDWARE`; events sharing a `group_key` are folded into one item | | `GET /changes/categories?days=7&include_backfill=0` | `{ items: { category, event_type, count }[] }` | | `GET /timeline?entity=&year=&category=&importance_min=&limit=&include_backfill=0&date_field=occurred` | `{ items: { month, count, events }[], total, date_field, include_backfill }` | | `GET /compare?ids=a,b,…&diff_only=0&mode=` | `{ entity_type, dimensions, items, comparability: Record, diff_only, note }` — **1.1:** benchmark dimensions keyed `bench:::` (only groups where EVERY model has a current result; dimension carries `comparability`, `trust_levels`), model dims add `reasoning`, `tool_calling`; `diff_only=1` keeps differing dimensions; `mode=models|providers|hardware|companies|frameworks|benchmarks` asserts the type (400 otherwise) | | `GET /diff?a=&b=&scope=&limit=&include=&include_backfill=0` | `{ a, b, scope, new_entities, gone_entities, property_changes, price_changes, benchmark_changes, new_benchmark_leaders: { benchmark, at_a, at_b }[], provider_changes, hardware_changes, context_changes, retired_models, counts, include_artifacts, include_backfill, note }` — **1.1:** events on `occurred_at`, not back-filled; `new_entities` excludes artifacts unless `include=artifacts` | | `GET /sources` | unchanged | | `GET /methodology` | v1 keys + **1.1:** `openness: { categories, labels, definitions, dimensions }`, `trust_levels`, `comparability` (rules, task/condition/ignored keys), `counters` (how counted), `anomaly_checks`, `event_semantics`, `hardware_fit`, `frontier`, `find_a_model`, `licence_categories` | | `GET /trending?days=7&limit=12&type=&kind=views` | `{ days, kind, items, definition }` — **1.1:** `kind=views` (v1, `views`) · `most_changed` · `new_listings` · `new_results` (each `events`, `last_event_at`); separate lists, never merged | | `POST /views` · `GET /sitemap` · `GET /api-keys/me` | unchanged | ### `EntityDetail` ```ts EntitySummary & { attributes: Record; provenance: Provenance; aliases: string[]; identifiers: { scheme, value }[] relations: { predicate, direction: "out"|"in", items: EntitySummary[], total }[]; sources: SourceRef[]; timeline: ChangeEvent[] // timeline: occurred_at order, not back-filled redirected_from?: { slug, id, entity_type } // 1.1: the caller asked for a merged/folded row — web should 301 to this detail's slug // model prices?: Price[]; price_history?: Price[]; results?: BenchmarkResult[]; lineage?: { ancestors, descendants, quantizations }; providers?: EntitySummary[] hardware_fit?: {…}[]; hardware_fit_assumptions?: string[]; papers?: EntitySummary[]; repositories?: EntitySummary[] family?: EntitySummary | { id: null, name, canonical: false, note } | null // 1.1 artifacts?: { items: { kind, items: (EntitySummary & { artifact_kind })[], count }[], total } // 1.1, grouped by artifact_kind deployments?: Deployment[] // 1.1 identity?: { canonical_model: true, official_checkpoints: string[], official_artifacts, third_party_artifacts, provider_deployments, folded_variants, api_aliases: string[], note } licence?: LicenseInfo & { raw, url_observed } | { key: null, raw, note } // 1.1 openness?: { category, raw, label, definition, dimensions: Record, note } // 1.1 version_history?: { property, transitions: { from, to, valid_from, valid_to, effective_at, source_url, tier, claim_id, status }[], current }[] // context_length, max_output_tokens, status, knowledge_cutoff, license, openness, parameter_count benchmarks?: { items: { id, slug, name, category, metrics: { metric, groups: { config_key, comparability_group, n_rows, higher_is_better, best: {…trust_level…}, trust_levels }[] }[] }[], total_rows, note } family_id?: string | null; identity_confidence?: "high"|"medium"|"low" // artifact (1.1) canonical?: EntitySummary | null; artifact_kind?: "checkpoint"|"quantization"|"conversion"|"packaging"|null // provider (1.1) deployments?: Deployment[] // current offers of this provider, cheapest output first removed?: Deployment[] // offers closed in the last 90 days (status delisted) // company / provider / hardware / framework / model_family models?: Page; papers?; repositories? } ``` ## 1.1 additions | route | returns | |---|---| | `GET /models/{a}/diff/{b}` | `{ a, b, dimensions: (Dimension & { a, b, delta: { absolute, percent } | { added, removed } | null })[], comparability, note }` — differing dimensions only | | `GET /entities/{slug}/claims?property=&status=current|all|superseded|conflicting|retracted&limit=&offset=` | `{ entity, items: (Claim & { snapshot_id, run_id, value_raw })[], total, limit, offset, status }` | | `GET /entities/{slug}/provenance/{property}` | evidence drawer: `{ entity, property, value, value_raw, unit, source: { id, name, domain, url }, tier, confidence, extractor, extractor_version, observed_at, effective_at, valid_since, claim_id, run_id, snapshot_id, snapshot: { id, observed_at, document_url, title, archived }, conflicts: Claim[], history_count, note }` (404 when no claim and no attribute) | | `GET /claims/{id}` | `{ claim, entity, property, chain: { previous: Claim[], superseding: Claim[], conflicting: Claim[], history_count }, source: { id, name, domain, tier, url, snapshot_id, observed_at }, extractor: { name, version, confidence }, run_id, evidence: { snapshot_id, document_url, archived, snapshot_observed_at, document_title, doc_type }, note }` | | `GET /benchmarks/{slug}/leaderboard?metric=&config_key=&trust=&org=&since=&until=&comparable_only=0&limit=100&offset=0` | `{ benchmark, group: Group, groups: Group[], items: LeaderboardRow[], total, limit, offset, comparable_only, filters, history_available, methodology }` — ONE row per canonical model (best current row in the group; default group = primary metric, most-populated config); `delta_rank` vs the ranking of the closed rows of the same group when history exists | | `GET /benchmarks/{slug}/frontier?metric=&config_key=` | `{ benchmark, series: { group: Group, primary: bool, points: { date, model: ModelRef, score, trust_level, config, result_id }[], current_leader }[], generated_at, methodology }` — leader history per group (a point each time a new best appears, ordered by `coalesce(evaluated_at, observed_at)`, closed rows included) | | `GET /benchmarks/matrix?benchmarks=a,b,c&models=…&org=&family=&since=&until=&limit=60&comparable_only=0&min_cells=3` | `{ columns: { id, slug, name, category, metric, config_key, group_label, higher_is_better, n_models }[], rows: { model: {…}, cells: Record, n_cells, mean_rank }[], total_rows, comparable_only, min_cells, filters, methodology }` (`since`/`until` bound the model `release_date`) — default columns = 12 benchmarks with most current results, default rows = models with ≥ `min_cells` cells (`mean_rank` is a sort key, not a score) | | `GET /deployments?model=&provider=&org=¤t=1&status=active|delisted|all&sort=valid_from|output|input|model|provider&limit=50&offset=&before=` | `Page & { next_before, current, status }` — `current=0` ⇒ `status=all` (active + delisted rows); `status=delisted` returns closed rows only (newest `valid_to` first) | | `GET /cost?model=&provider=&input_tokens=1000&output_tokens=500&requests_per_day=1000&cached_share=0..1&batch=0|1` | `{ model, inputs, items: { deployment: Deployment, cost: { per_request, daily, monthly, annual, effective_input_per_mtok, effective_output_per_mtok, per_request_fee, inputs, notes: string[] } }[], total, currency, methodology, note }` — cached/batch prices used only when published (else a `notes` entry) | | `GET /cost/context?tokens=1000000&limit=50&model=&org=` | `{ tokens, items: { deployment, context_length, context_source: "offer"|"model attribute", cost_usd }[], total, currency, methodology }` — offers whose context ≥ tokens, cheapest first | | `GET /frontier?limit=12` | `{ latest_major_models: ChangeEvent[], benchmark_frontier: { benchmark, group, leader, second, gap }[], price_frontier: { cheapest_output: Deployment, cheapest_output_1m_context, frontier_models, composition }, context_frontier: { model, context_length }[], open_weight_frontier: { items: { model, best_rank, best_rank_on, parameter_count, context_length, ranks }[], dimensions, note }, efficiency_frontier: { quality: { benchmark, group }, x, points: { id, model, x, y, rank, trust_level, pareto }[], frontier: string[] }, agentic_frontier: { benchmark, group, leaders: LeaderboardRow[] }[], multimodal_frontier: { model, modalities, top10_on }[], recent_frontier_movements: (ChangeEvent & { percent_change? })[], generated_at, methodology }` | | `GET /pareto?benchmark=&x=output_price|input_price|parameter_count|context_length|memory_estimate&y=score&metric=&config_key=&org=&family=&openness=` | `{ benchmark, group, groups, x: { key, label }, y, points: { id, model, x, y, rank, trust_level, config, context_length, parameter_count, release_date, provider?, estimated?, pareto }[], frontier: string[], methodology }` — `x=latency` → 400 (not stored, never estimated) | | `GET /pulse?days=7` | `{ days, since, until, counters: Record, note }` — keys: `new_models, new_open_weight_models, new_artifacts, new_papers, provider_listings, provider_delistings, price_changes (items: { id, summary, model, provider, occurred_at, percent_change, delta }[], median_percent), new_models_1m_context (items: EntitySummary[]), new_benchmark_leaders (items), documents_changed, sources_observed, events_total`; all `is_backfill=false`, `occurred_at` in window. Price deltas are read from the events' `{input_per_mtok, output_per_mtok}` old/new values (output first) | | `GET /open?sort=release|params|context|rank|name|downloads&license=&min_params=&max_params=&min_context=&modality=&days=&openness=&limit=&offset=` | `Page<{ model, licence: LicenseInfo & { raw } | { key: null, raw, note }, dimensions, best_results: { benchmark, rank }[], best_rank, hardware_fit: { "4bit_64gb": Fit, "8bit_128gb": Fit, estimated: true }, providers, cheapest_output_per_mtok }> & { summary: { by_category, by_license_top, new_30d, new_30d_definition }, note }` (`new_30d` = `release_date` in the last 30 days, `first_seen_at` only when no release date is known; `licence.key` is derived on read from the raw label when `license_key` is missing) — universe = downloadable weights (open-weights, open-source, restricted-weights) | | `GET /find-a-model?use_case=coding|reasoning|agentic|long_context|vision|low_cost|local|embeddings|chat&deployment=local|api|any&memory_gb=&quant=&context_min=&license=commercial|any&openness=&max_input_price=&max_output_price=&modalities=&limit=30` | `{ matches: { model, why: string[], observed: {…}, estimated_fit?: Fit, deployments?: Deployment[] }[], total, filters_applied, rules, note }` — deterministic rules (see `/methodology.find_a_model`), sorted by satisfied criteria → best benchmark rank → release date; **no composite score** | | `GET /run-locally?memory_gb=&gpu_count=1|2|4|8&quant=&context=8192&batch=1&platform=apple|nvidia|amd|any&use_case=&openness=&limit=` | `{ inputs, estimated: true, assumptions, counts, items: { model, fit: Fit, artifacts: { artifact, quant_format, file_size_gb, weights_source: "observed"|"estimated", fit: Fit }[], artifact_count }[], note }` — artifacts with an observed `file_size_gb` use it as the weights size | | `GET /hardware/{slug}/fit?quant=&context=&memory_gb=&gpu_count=&limit=&openness=` | `{ hardware, memory_options_gb, inputs, estimated: true, assumptions, runtimes, counts, items: ({ model } & Fit)[] }` | | `GET /families?q=&org=&sort=models|name|last_release&limit=&offset=` | `Page<{ id, slug, name, canonical: bool, entity_type: "model_family", organization, model_count, first_release, last_release, param_range, modalities, licenses, benchmark_best: Record }> & { note }` (members in `/families/{slug}` carry `benchmark_ranks` and `benchmark_best: { benchmark, rank, score, metric, config_key }[]`) — `model_family` entities + legacy `attributes.family` labels (`canonical: false`) | | `GET /families/{slug}?limit=` | `{ id, slug, name, canonical, summary, …aggregates, members: { model, key_facts, benchmark_ranks }[], artifacts_count, providers, lineage: { source, target, predicate }[], timeline, note }` — accepts a family label pre-canonicalisation | | `GET /graph/explore?node=&mode=lineage|research|company|benchmark|dataset|provider|hardware&depth=1|2&limit=150` | `{ root, mode, depth, predicates, nodes: { id, slug, name, entity_type, org, org_slug, level, artifact_kind, attributes }[], edges: { source, target, predicate, attributes, tier }[], truncated, counts }` — never more than `limit` nodes | | `GET /time-machine?date=YYYY-MM-DD&scope=models|prices|benchmarks|hardware|all&limit=50` | `{ date, scope, first_entity_at, reconstructed, note, models?: { items: { model, attributes_as_of, observed_then, reconstructed }[], total }, prices?: { items: Deployment[], total }, benchmarks?: { leaders: { benchmark, leader }[] }, hardware?: { items } }` — before `first_entity_at` the state is reconstructed from claims/release dates (`reconstructed: true`) | | `GET /licenses` · `GET /licenses/{key}?limit=` | `{ items: (LicenseInfo & { aliases, models })[], total, categories, unclassified: { raw, models }[], note }` · `LicenseInfo & { aliases, models: Page }` | ### Search compiler v2 (`/search`) `query` = `CompiledQuery & { compiled: { filter, label, value, source_span }[], sort, residual, unrecognised: string[], semantic, version: 2, note? }`. Recognised: `open|open-weights|open source`, `proprietary|closed`, `reasoning|thinking models`, `vision|multimodal|image|audio|video|embedding(s)`, `under/below/cheaper than $X/M [input|output]`, `fit(s) in|runs on N GB`, `on my mac`, `context ≥ 1M|1M context|long-context`, `since|after 2025`, `in 2026`, `released this year|last year`, `last N days|weeks|months`, `by |from | models…` (verified against organization names and aliases — an unknown name is demoted to free text and listed in `unrecognised`), `over|≥|more than 100B`, `under 30B`, `between 7B and 70B`, `apache|mit|bsd|cc-by|gpl|llama|gemma license`, `commercial use`, `papers …`, `benchmark `, `provider `, sort hints `cheapest|newest|largest|smallest|best`. `memory_gb` becomes an ESTIMATED parameter bound (4-bit, 8K context) — noted in `query.note`. All numeric casts in SQL are regex-guarded. ## Semantics changes (1.1) 1. **Models universe.** `/models`, `/hardware/fit`, `/open`, `/run-locally`, leaderboards, matrix, price index and `/stats.entities.model` count **canonical model releases**: `entity_type = 'model' and merged_into is null`. Artifacts (`entity_type = 'artifact'`: checkpoints, quantisations, conversions, packagings) and folded evaluation variants (rows with `merged_into`) are excluded. `include=artifacts` on `/models` restores the pre-1.1 universe. Every old slug keeps resolving: `/models/` returns the artifact detail (`canonical`, `artifact_kind`); `/models/` returns the canonical model with `redirected_from`. 2. **Event date fields.** `/changes`, `/changes/daily`, `/changes/categories`, `/timeline`, `/entities/{slug}/timeline`, `/diff`, `/pulse`, `/trending?kind≠views` use `occurred_at = coalesce(effective_at, observed_at)` and exclude `is_backfill = true` rows. `include_backfill=1` and `date_field=observed` restore the v1 behaviour; cursors follow the chosen field. `/stats.change_events_24h` keeps the v1 meaning (observed) and `change_events_live_24h` is the new "what happened today" counter. 3. **Leaderboard grouping.** Results are organised in comparability groups (canonical metric × `config_key`, the hash of task-defining configuration keys). `/benchmarks` `leader`/`top`, `/benchmarks/{slug}/leaderboard`, `/benchmarks/matrix`, `/compare` benchmark dimensions, `/pareto`, `/frontier` and `/find-a-model` ranks are computed per group with one row per canonical model. `/benchmarks/{slug}/results` keeps the v1 one-row-per-result listing. Rows written before migration 0003 (NULL `config_key`/`trust_level`) are recomputed on read from `config` and the source key, so the API behaves identically before and after canonicalisation. Also: `/companies.total == /stats.organizations_total`; `/benchmarks/{slug}` resolves aliases (`GPQA Diamond`); `/entities/{slug}/related` uses `family_id` when present; `x-forwarded-for` is honoured only when `settings.trust_proxy` / `AIA_TRUST_PROXY=1`. ## Deprecations None. No v1 route, parameter, field or type was removed or retyped in 1.1. ## Admin routes (`x-aia-admin-token`; rate limit 240/min per IP evaluated BEFORE the token; 10 failed auths/min per IP → 429; every call except the polling GETs `overview`, `infrastructure`, `llm/health`, `audit` writes an `admin_audit_log` row `{ actor: 'admin', action: ' ', target, payload: { method, path, query?, body? }, ip }`) | route | returns / action | |---|---| | `GET /admin/overview` · `GET /admin/connectors` · `POST /admin/connectors/{name}/run` · `PATCH /admin/connectors/{name}` · `GET /admin/runs` · `GET /admin/errors` | v1 (unchanged) | | `GET /admin/documents…` · `GET /admin/documents/{id}` · `GET /admin/snapshots/{id}` · `GET /admin/jobs` · `POST /admin/jobs/{id}/retry` · `POST /admin/jobs/requeue-dead` · `GET /admin/llm-jobs` · `GET /admin/llm/health` · `POST /admin/llm/enqueue` · `POST /admin/reprocess` | v1 (unchanged) | | `GET /admin/review` · `POST /admin/review/{id}` · `GET /admin/entities/duplicates?type=&limit=&threshold=` · `POST /admin/entities/merge` · `POST /admin/entities/{id}/claims/{claim_id}/retract` | v1 — **1.1:** `duplicates` no longer calls `set_limit()` (GUC leak): the `%` operator uses the index at the default 0.3 threshold and `similarity() > :threshold` applies the exact bound | | `GET /admin/infrastructure` · `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | v1 (unchanged) | | `POST /admin/cache/flush?prefix=&after_run=0` | `{ flushed, prefix }`; `after_run=1` → `{ flushed, by_prefix }` over the read paths a connector run invalidates (`/api/v1/stats`, `/changes*`, `/benchmarks*`, `/prices*`, `/models*`, `/deployments`, `/frontier`, `/pulse`, `/open`, `/families`, `/timeline`, `/diff`, `facets:`, `frontier:`). The scheduler calls `services.cache.cache_invalidate(prefix)` with the same prefixes after each run (Stream A). | | **`GET /admin/quality`** | data-health dashboard: `duplicate_candidates { pending_decisions, review_merge_candidates }`, `taxonomy_violations { unmapped_taxonomy_rows, openness_unknown_vocab, status_unknown_vocab, license_unclassified }`, `impossible_values { count, by_check, sample }`, `conflicting_t1_claims`, `models_without_organization`, `models_without_release_source`, `models_without_parameters`, `orphan_benchmark_results` (model merged or artifact), `benchmarks_without_results`, `unresolved_provider_deployments` (provider_model_id matching no identifier), `quantisations_typed_as_models` (ontology name analysis says artifact), `stale_sources` (last success > 3× interval), `empty_public_categories`, `quarantined_runs_pending` — each `{ count, sample }` — plus `review_queue_priority: { kind, id, reasons: string[], … }[]` (frontier models, benchmark leaders, largest params/context claims, price anomalies, major orgs, duplicates of frontier models first) | | **`GET /admin/entity-resolution?type=model&status=pending|decided|all&limit=&threshold=0.8`** | `{ items: { a: Side, b: Side, signals: { kind: review_merge_candidate|trigram_similarity|same_variant_key, … }[], similarity, same_variant_key, same_organization, decision, hint }[], total, decisions }` — `Side` = names, org, family, params, release date, architecture, hf_repo, identifiers, aliases, relations/sources/claims/prices/results counts, `variant_key`, `name_analysis` | | **`POST /admin/entity-resolution/{a}/{b}`** `{ decision: merge|alias|variant_of|family_member|keep_separate|defer, note? }` | persists `resolution_decisions` (upsert on a, b, decision) and applies: `merge_entities(conn, a, b, mode=)` when the service signature accepts `mode` (checked with `inspect.signature`), otherwise plain merge for `merge`, `canonical_id` + merge for `variant_of`, alias row for `alias`, `family_id` for `family_member` (b must be a `model_family`); matching pending `merge_candidate` review items are approved → `{ ok, a, b, decision, applied, effect }` | | **`GET /admin/anomalies?status=open|all&severity=&check=&limit=&offset=`** · **`POST /admin/anomalies/{id}`** `{ status: resolved|ignored|open, note? }` | anomaly flags (never deleted) | | **`GET /admin/extractions/{snapshot_id}?text_limit=`** | extraction debugger: snapshot metadata (no paths), `text` (≤ 20 kB cleaned text), `structured`, `diff`, `previous_snapshot`, `claims`, `relations`, `results`, `prices`, `events`, `llm_jobs`, `entity_candidates`, `spans: { claim_id, property, value, found, offset?, match?, context?, tried? }[]`, `spans_found` — value location is best effort, not-found is reported | | **`GET /admin/quarantine?status=pending|all`** · **`POST /admin/quarantine/{id}`** `{ action: release|discard, note? }` | held facts; the POST calls `services.canonical.release_quarantine/discard_quarantine` (imported lazily) → 501 with a note while Stream A has not shipped them | | **`GET /admin/audit?limit=&offset=&action=`** | `{ items: { id, actor, action, target, payload, ip, created_at }[], total }` | | **`POST /admin/runs/{run_id}/rollback`** | retracts claims (`status = retracted`), closes relations / prices / results (`valid_to`, `is_current = false`), flags events `is_backfill = true` + `meta.rolled_back`, re-materialises attributes from the best remaining claim — only rows with that `run_id`; deletes nothing → `{ ok, run_id, connector, counts, note }` | ## Implementation notes (`src/aiatlas/api`) - **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds return **422** with `{ detail, errors }`; semantic errors raised by the routes return **400**. - **Caching**: public GETs are cached in Redis (`aia:api::?`, 60–600 s) where `` = 8-hex sha1 of `DATABASE_URL` — two API processes on different databases (dev :8331, prod copy :8332) sharing one Redis db never read each other's bodies or derived id sets (the frontier id set is also re-validated against live canonical models on every cache hit) — and served with a weak ETag (`W/"sha1(body)"`) computed on the uncompressed body inside GZip; `/stats` stays at 60 s. Cache invalidation: `services.cache.cache_invalidate(prefix)`; `POST /admin/cache/flush?after_run=1`. - **Connections**: `EntityDetail` runs its blocks in ≤ 4 concurrent groups (one pooled connection each, sequential inside a group) instead of one connection per block. - **Performance (2026-09-12, prod copy, EXPLAIN ANALYZE)**: leaderboard load 8 ms · matrix / all groups 8 ms · models listing 0.5 ms (+ count 1.2 ms, facets ≤ 4 ms) · changes feed 0.8 ms (count 3 ms) · daily sections 0.4 ms · price index 180 d 9 ms · frontier composition 1.2 ms · stats 7 ms · deployments cursor 0.4 ms · providers 2.5 ms · free-text search 16 ms. No query exceeded 200 ms, so **no migration 0004 was needed** (the 0003 partial indexes on `(is_backfill, occurred_at)`, `(benchmark_id, config_key, is_current)`, `family_id`, `canonical_id` are used). The admin trigram self-join (`/admin/entities/duplicates`, `/admin/entity-resolution`) takes ~120 ms in SQL + per-pair side-by-side lookups; it is admin-only and not cached. - **Search**: `query.semantic` says whether an embedding was used; on embedding timeout (2 s) the API backs off to FTS-only for 5 minutes. - **`hardware_fit`** (model detail) is omitted when `parameter_count` is unknown; every fit payload carries `estimated: true`. - **Rate limits** are in-process sliding windows (search 60/min, views 1/s, admin 240/min, admin-auth-failed 10/min per IP) — fine for the single uvicorn worker behind Next.js. - **Migrations**: `0002_api_indexes` (read paths), `0003_canonical_ontology` (hierarchy, event semantics, comparability, admin tables) — both required by 1.1.