API 1.1 (Stream C): canonical model universe, comparability-grouped leaderboards, deployments & cost, intelligence surfaces, admin workbenches
Existing routes (additive, v1 shapes kept): /models canonical universe + include=artifacts, family_id join, canonical licence
matching, families/licenses/trust facets, /models/{slug} accepts artifacts and reports redirected_from; EntityDetail adds family,
artifacts, deployments, identity, licence, openness, version_history, grouped benchmarks and runs in <= 4 connection groups; /stats
adds organizations_total, artifacts, model_families, change_events_live_24h and counter definitions; /changes, /changes/daily
("Today in AI 2.0" with group_key folding), /timeline, entity timelines and /diff default to is_backfill=false on occurred_at
(include_backfill, date_field restore v1); /benchmarks exposes family/variant/metric/direction/groups/trust_mix and resolves aliases;
/compare groups benchmark dimensions by (benchmark, metric, config_key) with comparability, diff_only and mode; /prices adds
family/org/modality filters and sort=cheapest_frontier; /prices/index becomes the AI Price Index; /providers adds distributions,
30-day churn, organizations_covered, features_supported; /search compiler v2 (compiled/sort/residual/unrecognised, org verified
against aliases, guarded casts, FTS fallback when pgvector is absent); /methodology, /trending?kind=.
New routes: /benchmarks/{slug}/leaderboard, /benchmarks/{slug}/frontier, /benchmarks/matrix, /models/{a}/diff/{b}, /deployments,
/cost, /cost/context, /frontier, /pareto, /pulse, /open, /find-a-model, /run-locally, /hardware/{slug}/fit, /families,
/families/{slug}, /graph/explore, /time-machine, /claims/{id}, /entities/{slug}/claims, /entities/{slug}/provenance/{property},
/licenses, /licenses/{key}; admin: /admin/quality, /admin/entity-resolution (GET + POST decisions via merge_entities mode= when
available), /admin/anomalies, /admin/extractions/{snapshot_id}, /admin/quarantine (501 until services.canonical ships),
/admin/audit, /admin/runs/{run_id}/rollback, /admin/cache/flush?after_run=1.
Services: frontier.py (comparability groups, one-row-per-model leaderboards, frontier model set, leader_at), finder.py, pareto.py,
cost.py, hardware_fit.py (architecture-aware KV cache, observed artifact sizes, multi-GPU), search.py v2, stats.py definitions.
Security/perf: rate_limit("admin") before require_admin, failed-auth limiter 10/min/IP, x-forwarded-for only with AIA_TRUST_PROXY,
set_limit GUC leak removed, weak ETag + Cache-Control + 304 on public GETs, admin audit log on every admin call. EXPLAIN ANALYZE on the
production copy: every read path < 20 ms, no index migration needed.
Docs: docs/API.md rewritten as the 1.1 contract (changelog, 1.1 additions, semantics changes, deprecations: none).
Tests: tests/test_api_v11.py (contract) and tests/test_api_services.py (pareto, cost, compiler, hardware fit); 44 API tests pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
34 changed files +4,815 −476
modified
docs/API.md
+175 −105
@@ -1,10 +1,24 @@ | ||
| 1 | −# AI Atlas API — contract (v1) | |
| 1 | +# AI Atlas API — contract (API 1.1, mounted at `/api/v1`) | |
| 2 | 2 | |
| 3 | 3 | Base: `/api/v1` (FastAPI, `src/aiatlas/api`). JSON, UTF-8, ISO-8601 UTC timestamps, numbers as numbers (Postgres aggregates may arrive as |
| 4 | −strings — the web layer normalises). Public routes are cached in Redis (`aia:api:*`, 60–600 s) and rate-limited per IP on search. | |
| 5 | −Admin routes require header `x-aia-admin-token: <AIA_ADMIN_TOKEN>`. OpenAPI at `/api/v1/docs`. Errors: `{"detail": "..."}` with 400/404/429/503. | |
| 4 | +strings — the web layer normalises). Public routes are cached in Redis (`aia:api:*`, 60–600 s), carry a weak `ETag` + `Cache-Control: public, | |
| 5 | +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 | |
| 6 | +`x-api-version: 1.1`. Admin routes require header `x-aia-admin-token: <AIA_ADMIN_TOKEN>` and are rate-limited **before** the token check | |
| 7 | +(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. | |
| 6 | 8 | Never expose credentials, internal hostnames, raw archive content or `raw_path`s. |
| 7 | 9 | |
| 10 | +**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 | |
| 11 | +below; three defaults changed semantics (see "Semantics changes") and each has a parameter that restores the v1 behaviour. Every number | |
| 12 | +that is derived or estimated comes with a `methodology`, `note`, `definition` or `estimated: true` string next to it; when data is missing the | |
| 13 | +value is `null`/empty with a note — nothing is fabricated. | |
| 14 | + | |
| 15 | +## Changelog | |
| 16 | + | |
| 17 | +| version | date | changes | | |
| 18 | +|---|---|---| | |
| 19 | +| **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. | | |
| 20 | +| 1.0 | 2026-09-11 | Initial contract (table "v1 routes" below). | | |
| 21 | + | |
| 8 | 22 | ## Shared shapes |
| 9 | 23 | |
| 10 | 24 | ```ts |
@@ -19,132 +33,188 @@ type Provenance = Record<string, { source_id: string | null; source_name?: strin | ||
| 19 | 33 | confidence: string; extractor: string; unit?: string }> |
| 20 | 34 | type ChangeEvent = { id: string; event_type: string; category: string; property: string | null; old_value: unknown; new_value: unknown; |
| 21 | 35 | summary: string; importance: 0 | 1 | 2 | 3; observed_at: string; effective_at: string | null; source_url: string | null; connector_name: string | null; |
| 22 | − entity: EntitySummary | null; meta: Record<string, unknown> } | |
| 36 | + entity: EntitySummary | null; meta: Record<string, unknown>; | |
| 37 | + occurred_at?: string; is_backfill?: boolean; group_key?: string | null } // 1.1 (feeds) | |
| 23 | 38 | type Price = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; input_per_mtok: Num; output_per_mtok: Num; |
| 24 | 39 | 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; |
| 25 | 40 | context_length: Num; max_output_tokens: Num; features: Record<string, unknown>; observed_at: string; valid_from: string; valid_to: string | null; source_url: string | null; tier: number } |
| 26 | 41 | type BenchmarkResult = { id: string; model: EntitySummary; benchmark: EntitySummary; score: number; metric: string | null; unit: string | null; |
| 27 | − higher_is_better: boolean; config: Record<string, unknown>; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; confidence: string } | |
| 42 | + higher_is_better: boolean; config: Record<string, unknown>; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; confidence: string; | |
| 43 | + valid_to: string | null; config_key?: string | null; trust_level?: string | null } // 1.1 adds config_key/trust_level where returned | |
| 28 | 44 | type Claim = { id: string; property: string; value: unknown; unit: string | null; tier: number; confidence: string; status: string; extractor: string; |
| 29 | 45 | observed_at: string; effective_at: string | null; valid_from: string; valid_to: string | null; source_url: string | null; source_name: string | null } |
| 30 | 46 | 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 } |
| 47 | + | |
| 48 | +// ---- 1.1 shapes | |
| 49 | +type Deployment = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; context_length: Num; max_output_tokens: Num; | |
| 50 | + 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; | |
| 51 | + unit: "USD per 1M tokens"; native_units: Record<string, unknown> }; // provider-specific priced features kept verbatim (flex_*, priority_*, *_per_1k_requests…) | |
| 52 | + features: Record<string, unknown>; status: "active" | "delisted"; observed_at: string; valid_from: string; valid_to: string | null; source_url: string | null; tier: number } | |
| 53 | +type Group = { metric: string; config_key: string; label: string; n: number; model_count: number; config: Record<string, unknown>; higher_is_better: boolean; trust_mix: Record<string, number> } | |
| 54 | +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; | |
| 55 | + trust_level: string; trust_label: string; config: Record<string, unknown>; config_key: string; comparability: "comparable" | "partially-comparable" | "not-comparable"; | |
| 56 | + comparability_reasons: string[]; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; result_id: string; n_rows: number } | |
| 57 | +type ModelRef = { id: string; slug: string; name: string; entity_type: string; organization: Org; attributes: Record<string, unknown> } // openness/params/context/release/modalities/license/family | |
| 58 | +type LicenseInfo = { key: string; label: string; category: string; spdx: string | null; url: string | null; commercial_use: boolean | null; redistribution: boolean | null; | |
| 59 | + derivatives: boolean | null; hosting_restrictions: boolean | null; attribution: boolean | null; acceptable_use: boolean | null; osi_approved: boolean; weights_downloadable: boolean } | |
| 60 | +type Fit = { quantization: string; estimated: true; fits: boolean; estimated_memory_gb: number; headroom_gb: number; parameter_count?: number; | |
| 61 | + 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 }; | |
| 62 | + device: { memory_gb: number; gpu_count: number; total_memory_gb: number }; note: string; multi_gpu_note?: string } | |
| 31 | 63 | ``` |
| 32 | 64 | |
| 33 | −## Public routes | |
| 65 | +## v1 routes (unchanged unless noted "1.1:") | |
| 34 | 66 | |
| 35 | 67 | | route | returns | |
| 36 | 68 | |---|---| |
| 37 | −| `GET /health` | `{ status: "ok"|"degraded", version, db: bool, redis: bool, llm: { available, reachable? }, time }` | | |
| 38 | −| `GET /stats` | `{ entities: Record<type, number>, 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: { raw_bytes, raw_files, text_bytes, text_files }, computed_at }` — **always live from the DB** | | |
| 69 | +| `GET /health` | `{ status: "ok"|"degraded", version, api_version: "1.1", db: bool, redis: bool, llm: { available, reachable? }, time }` | | |
| 70 | +| `GET /stats` | `{ entities: Record<type, number>, 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<key, string>` ("How counted"). Cached 60 s. | | |
| 39 | 71 | | `GET /stats/history?days=90` | `{ items: { day: string; counts: object }[] }` | |
| 40 | −| `GET /search?q=&type=&limit=30&offset=0` | `{ query: CompiledQuery, items: (EntitySummary & { rank: number })[], total: number }` — uses `services.search.compile_query` + `search_entities` (+ embedding when the gateway is up) | | |
| 72 | +| `GET /search?q=&type=&limit=30&offset=0` | `{ query: CompiledQuery, items: (EntitySummary & { rank: number })[], total: number }` — **1.1:** compiler v2 (below) | | |
| 41 | 73 | | `GET /search/suggest?q=` | `{ items: { id, entity_type, slug, name, organization_name }[] }` (≤ 8, prefix, fast) | |
| 42 | −| `GET /entities/{slug_or_id}` | `EntityDetail` (below). Also mounted as `/models/{slug}`, `/companies/{slug}`, `/papers/{slug}`, `/providers/{slug}`, `/benchmarks/{slug}`, `/hardware/{slug}`, `/frameworks/{slug}`, `/datasets/{slug}`, `/tools/{slug}` (404 if the type doesn't match) | | |
| 43 | −| `GET /entities/{slug}/timeline?limit=50&before=` | `{ items: ChangeEvent[] }` (entity's own events + events of entities it develops for companies) | | |
| 74 | +| `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) | | |
| 75 | +| `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`) | | |
| 44 | 76 | | `GET /entities/{slug}/history?property=` | `{ items: Claim[] }` — full claim history (all statuses), newest first | |
| 45 | −| `GET /entities/{slug}/asof?date=YYYY-MM-DD` | `{ existed: bool, first_seen_at, date, attributes: Record<string, unknown>, claims: Claim[] }` — state as known at that date | | |
| 46 | −| `GET /entities/{slug}/graph?depth=1&limit=80` | `{ nodes: { id, slug, name, entity_type, organization_name? }[], edges: { source, target, predicate }[] }` (depth ≤ 2, cap nodes) | | |
| 77 | +| `GET /entities/{slug}/asof?date=YYYY-MM-DD` | `{ existed: bool, first_seen_at, date, attributes, claims: Claim[] }` | | |
| 78 | +| `GET /entities/{slug}/graph?depth=1&limit=80` | `{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] }` | | |
| 47 | 79 | | `GET /entities/{slug}/sources` | `{ items: SourceRef[] }` | |
| 48 | −| `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family / shared relations) | | |
| 49 | −| `GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=(updated|name|params|context|release|quality|downloads)&order=&limit=&offset=` | `Page<EntitySummary>` + `facets` when `facets=1`: `{ organizations: {slug,name,count}[], openness: {value,count}[], modalities, families, years, licenses, status }` | | |
| 50 | −| `GET /companies?q=&country=&kind=&sort=(models|name|updated|quality)&limit=&offset=` | `Page<EntitySummary & { model_count: number; paper_count: number }>` (+`facets`: countries, kinds) | | |
| 51 | −| `GET /papers?q=&category=&org=&since=&until=&sort=(published|updated)&limit=&offset=` | `Page<EntitySummary>` | | |
| 52 | −| `GET /providers` | `{ items: (EntitySummary & { model_count: number; price_count: number; min_input_per_mtok: Num; min_output_per_mtok: Num })[] }` | | |
| 53 | −| `GET /prices?model=&provider=&sort=(input|output|model|provider|observed)&limit=&offset=¤t=1` | `Page<Price>` | | |
| 54 | −| `GET /prices/history?model=&provider=` | `{ items: Price[] }` (all rows incl. closed, oldest first) | | |
| 55 | −| `GET /prices/index?days=180` | `{ series: { day: string; median_input: Num; median_output: Num; min_input: Num; models: number }[]; movers: ChangeEvent[] }` computed from `prices` history | | |
| 56 | −| `GET /benchmarks` | `{ items: (EntitySummary & { result_count: number; model_count: number; top: { model: EntitySummary; score: number } | null })[] }` | | |
| 57 | −| `GET /benchmarks/{slug}/results?limit=&offset=&config=` | `Page<BenchmarkResult>` sorted by score (respecting `higher_is_better`), current rows only unless `history=1` | | |
| 58 | −| `GET /benchmarks/{slug}/history?model=` | `{ items: BenchmarkResult[] }` over time | | |
| 59 | −| `GET /hardware?kind=&manufacturer=&min_memory=&sort=` | `Page<EntitySummary>` | | |
| 60 | −| `GET /hardware/fit?memory_gb=&quant=(4bit|8bit|fp16)&context=8192&limit=` | `{ inputs, assumptions: string[], items: { model: EntitySummary; parameter_count: number; estimated_memory_gb: number; fits: boolean; headroom_gb: number; quantization: string; note: string }[] }` — **ESTIMATED**: bytes/param (4bit 0.5+overhead 1.15, 8bit 1.0, fp16 2.0) + KV cache estimate; label as estimated | | |
| 61 | −| `GET /explore/types` | `{ items: { entity_type, count, label }[] }` | | |
| 62 | −| `GET /explore/{type}?q=&org=&sort=&limit=&offset=` | `Page<EntitySummary>` generic listing for any type (datasets, frameworks, tools, repositories, regulation…) | | |
| 63 | −| `GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&limit=50&before=<observed_at cursor>` | `Page<ChangeEvent>` newest first; `type` = comma list of event types; excludes `DOCUMENT_CHANGED` unless `include_documents=1` | | |
| 64 | −| `GET /changes/daily?date=YYYY-MM-DD` | `{ date, counts: Record<category, number>, sections: { category, label, items: ChangeEvent[] }[], new_models: EntitySummary[] }` ("What changed in AI today", DB-generated only) | | |
| 65 | −| `GET /changes/categories?days=7` | `{ items: { category, event_type, count }[] }` | | |
| 66 | −| `GET /timeline?entity=&year=&category=&limit=` | `{ items: { month: string; events: ChangeEvent[] }[] }` grouped by month; global when no entity | | |
| 67 | −| `GET /compare?ids=slug,slug,…` (2–6) | `{ entity_type, dimensions: { key, label, unit?, kind: "number"|"text"|"list"|"bool"|"date" }[], items: { entity: EntitySummary; values: Record<key, unknown>; provenance: Provenance; prices?: Price[]; results?: BenchmarkResult[] }[] }` — dimensions per type: model (params, active params, context, max output, openness, license, modalities, release date, knowledge cutoff, best price in/out, benchmark scores shared by all), provider (models, min prices, features), hardware (memory, bandwidth, tdp, runtimes), framework (version, license, stars), company (country, founded, models, papers) | | |
| 68 | −| `GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=(all|models|org:<slug>|family:<name>)` | `{ a, b, new_entities: EntitySummary[], gone_entities: EntitySummary[], property_changes: ChangeEvent[], price_changes: ChangeEvent[], benchmark_changes: ChangeEvent[], counts }` | | |
| 69 | −| `GET /sources` | `{ items: { key, name, domain, tier, kind, category, organization: Org, enabled, documents, last_crawled_at, connectors: { name, label, health, last_success_at, interval_seconds }[] }[] }` (public transparency page) | | |
| 70 | −| `GET /methodology` | `{ metrics: metric_definitions[], confidence_levels, tiers, event_types, extractors }` | | |
| 71 | −| `GET /trending?days=7&limit=12` | `{ items: (EntitySummary & { views: number })[] }` from `page_views` | | |
| 72 | −| `POST /views` `{ path }` | `{ ok: true }` (beacon; 1 req/s/IP) | | |
| 73 | −| `GET /sitemap?type=&limit=5000&offset=0` | `{ items: { slug, entity_type, updated_at }[], total }` | | |
| 74 | −| `GET /api-keys/me` (header `x-api-key`) | `{ label, plan, rate_per_min, usage_count }` — developer keys (later); public routes work without a key | | |
| 80 | +| `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family (`family_id` or label) / shared relations) | | |
| 81 | +| `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<EntitySummary & { identity_confidence, family?: {id,slug,name}, canonical?: EntitySummary, artifact_kind? }> & { universe }` — **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`. | | |
| 82 | +| `GET /companies?q=&country=&kind=&sort=&limit=&offset=&facets=1` | `Page<EntitySummary & { model_count, paper_count }>` — covers `company|organization|lab|university`; `total` = `/stats.organizations_total` | | |
| 83 | +| `GET /papers?…` | `Page<EntitySummary>` | | |
| 84 | +| `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 | | |
| 85 | +| `GET /prices?model=&provider=&sort=&limit=&offset=¤t=1&family=&org=&modality=` | `Page<Price>` — **1.1:** `family`, `org`, `modality` filters; `sort=cheapest_frontier` (frontier models only, cheapest output first, + `methodology`) | | |
| 86 | +| `GET /prices/history?model=&provider=` | `{ items: Price[] }` | | |
| 87 | +| `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, delistings_30d, price_changes_30d, frontier: { composition, methodology }, methodology, note }` — v1 keys kept | | |
| 88 | +| `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 | | |
| 89 | +| `GET /benchmarks/{slug}` | `EntityDetail` + `family, variant, metric, direction, category, groups, primary_group, result_count, model_count, leaderboard (top 25), trust_mix` | | |
| 90 | +| `GET /benchmarks/{slug}/results?limit=&offset=&config=&history=&metric=&config_key=` | `Page<BenchmarkResult>` — v1 behaviour (one row per result) + `metric` / `config_key` filters | | |
| 91 | +| `GET /benchmarks/{slug}/history?model=` | `{ benchmark, items: BenchmarkResult[] }` | | |
| 92 | +| `GET /hardware?…` | `Page<EntitySummary>` + facets | | |
| 93 | +| `GET /hardware/fit?memory_gb=&quant=&context=&limit=&openness=` | v1 estimate (unchanged) | | |
| 94 | +| `GET /explore/types` · `GET /explore/{type}` | unchanged (`artifact`, `model_family` appear as types once present) | | |
| 95 | +| `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<ChangeEvent> & { next_before, date_field, include_backfill }` — **1.1:** defaults `is_backfill=false`, `occurred_at`; cursor/filters follow `date_field` | | |
| 96 | +| `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 | | |
| 97 | +| `GET /changes/categories?days=7&include_backfill=0` | `{ items: { category, event_type, count }[] }` | | |
| 98 | +| `GET /timeline?entity=&year=&category=&importance_min=&limit=&include_backfill=0&date_field=occurred` | `{ items: { month, count, events }[], total, date_field, include_backfill }` | | |
| 99 | +| `GET /compare?ids=a,b,…&diff_only=0&mode=` | `{ entity_type, dimensions, items, comparability: Record<dimKey, { level, reasons, trust }>, diff_only, note }` — **1.1:** benchmark dimensions keyed `bench:<benchmark>:<metric>:<config_key>` (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) | | |
| 100 | +| `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` | | |
| 101 | +| `GET /sources` | unchanged | | |
| 102 | +| `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` | | |
| 103 | +| `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 | | |
| 104 | +| `POST /views` · `GET /sitemap` · `GET /api-keys/me` | unchanged | | |
| 75 | 105 | |
| 76 | 106 | ### `EntityDetail` |
| 77 | 107 | |
| 78 | 108 | ```ts |
| 79 | 109 | EntitySummary & { |
| 80 | − attributes: Record<string, unknown> // full | |
| 81 | − provenance: Provenance | |
| 82 | − aliases: string[]; identifiers: { scheme: string; value: string }[] | |
| 83 | − relations: { predicate: string; direction: "out" | "in"; items: EntitySummary[]; total: number }[] // grouped, ≤ 24 per group | |
| 84 | − sources: SourceRef[] // documents describing this entity (deduped by URL) | |
| 85 | − timeline: ChangeEvent[] // latest 30 | |
| 86 | − quality: {...}; counts: {...} | |
| 87 | − // type-specific blocks (present only when relevant) | |
| 88 | − prices?: Price[] // current rows across providers (model) or for this provider (provider) | |
| 89 | − price_history?: Price[] // closed + current, oldest first (model) | |
| 90 | − results?: BenchmarkResult[] // model: its results · benchmark: leaderboard top 100 | |
| 91 | − lineage?: { ancestors: EntitySummary[]; descendants: EntitySummary[]; quantizations: EntitySummary[] } // model | |
| 92 | − providers?: EntitySummary[] // model | |
| 93 | − hardware_fit?: { hardware: EntitySummary; quantization: string; estimated_memory_gb: number; fits: boolean }[] // model, ESTIMATED | |
| 94 | − models?: Page<EntitySummary> // company / provider / hardware(runnable) | |
| 95 | − papers?: EntitySummary[] // company / model | |
| 96 | − repositories?: EntitySummary[] // company / model / framework | |
| 110 | + attributes: Record<string, unknown>; provenance: Provenance; aliases: string[]; identifiers: { scheme, value }[] | |
| 111 | + relations: { predicate, direction: "out"|"in", items: EntitySummary[], total }[]; sources: SourceRef[]; timeline: ChangeEvent[] // timeline: occurred_at order, not back-filled | |
| 112 | + redirected_from?: { slug, id, entity_type } // 1.1: the caller asked for a merged/folded row — web should 301 to this detail's slug | |
| 113 | + // model | |
| 114 | + prices?: Price[]; price_history?: Price[]; results?: BenchmarkResult[]; lineage?: { ancestors, descendants, quantizations }; providers?: EntitySummary[] | |
| 115 | + hardware_fit?: {…}[]; hardware_fit_assumptions?: string[]; papers?: EntitySummary[]; repositories?: EntitySummary[] | |
| 116 | + family?: EntitySummary | { id: null, name, canonical: false, note } | null // 1.1 | |
| 117 | + artifacts?: { items: { kind, items: (EntitySummary & { artifact_kind })[], count }[], total } // 1.1, grouped by artifact_kind | |
| 118 | + deployments?: Deployment[] // 1.1 | |
| 119 | + identity?: { canonical_model: true, official_checkpoints: string[], official_artifacts, third_party_artifacts, provider_deployments, folded_variants, api_aliases: string[], note } | |
| 120 | + licence?: LicenseInfo & { raw, url_observed } | { key: null, raw, note } // 1.1 | |
| 121 | + openness?: { category, raw, label, definition, dimensions: Record<dim, boolean|null>, note } // 1.1 | |
| 122 | + 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 | |
| 123 | + 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 } | |
| 124 | + family_id?: string | null; identity_confidence?: "high"|"medium"|"low" | |
| 125 | + // artifact (1.1) | |
| 126 | + canonical?: EntitySummary | null; artifact_kind?: "checkpoint"|"quantization"|"conversion"|"packaging"|null | |
| 127 | + // company / provider / hardware / framework / model_family | |
| 128 | + models?: Page<EntitySummary>; papers?; repositories? | |
| 97 | 129 | } |
| 98 | 130 | ``` |
| 99 | 131 | |
| 100 | −## Admin routes (`x-aia-admin-token`) | |
| 132 | +## 1.1 additions | |
| 133 | + | |
| 134 | +| route | returns | | |
| 135 | +|---|---| | |
| 136 | +| `GET /models/{a}/diff/{b}` | `{ a, b, dimensions: (Dimension & { a, b, delta: { absolute, percent } | { added, removed } | null })[], comparability, note }` — differing dimensions only | | |
| 137 | +| `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 }` | | |
| 138 | +| `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) | | |
| 139 | +| `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 }` | | |
| 140 | +| `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 | | |
| 141 | +| `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) | | |
| 142 | +| `GET /benchmarks/matrix?benchmarks=a,b,c&models=…&org=&family=&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<benchmarkId, { score, rank, trust_level, config_key, comparability, result_id } | null>, n_cells, mean_rank }[], total_rows, comparable_only, min_cells, methodology }` — default columns = 12 benchmarks with most current results, default rows = models with ≥ `min_cells` cells (`mean_rank` is a sort key, not a score) | | |
| 143 | +| `GET /deployments?model=&provider=&org=¤t=1&sort=valid_from|output|input|model|provider&limit=50&offset=&before=<valid_from cursor>` | `Page<Deployment> & { next_before, current }` | | |
| 144 | +| `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) | | |
| 145 | +| `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 | | |
| 146 | +| `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 }` | | |
| 147 | +| `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, provider?, estimated?, pareto }[], frontier: string[], methodology }` — `x=latency` → 400 (not stored, never estimated) | | |
| 148 | +| `GET /pulse?days=7` | `{ days, since, until, counters: Record<key, { value, definition, median_percent?, items? }>, note }` — keys: `new_models, new_open_weight_models, new_artifacts, new_papers, provider_listings, provider_delistings, price_changes, new_models_1m_context, new_benchmark_leaders, documents_changed, sources_observed, events_total`; all `is_backfill=false`, `occurred_at` in window | | |
| 149 | +| `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 }, note }` — universe = downloadable weights (open-weights, open-source, restricted-weights) | | |
| 150 | +| `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** | | |
| 151 | +| `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 | | |
| 152 | +| `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)[] }` | | |
| 153 | +| `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 }> & { note }` — `model_family` entities + legacy `attributes.family` labels (`canonical: false`) | | |
| 154 | +| `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 | | |
| 155 | +| `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 | | |
| 156 | +| `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`) | | |
| 157 | +| `GET /licenses` · `GET /licenses/{key}?limit=` | `{ items: (LicenseInfo & { aliases, models })[], total, categories, unclassified: { raw, models }[], note }` · `LicenseInfo & { aliases, models: Page<EntitySummary> }` | | |
| 158 | + | |
| 159 | +### Search compiler v2 (`/search`) | |
| 160 | + | |
| 161 | +`query` = `CompiledQuery & { compiled: { filter, label, value, source_span }[], sort, residual, unrecognised: string[], semantic, version: 2, note? }`. | |
| 162 | +Recognised: `open|open-weights|open source`, `proprietary|closed`, `reasoning|thinking models`, `vision|multimodal|image|audio|video|embedding(s)`, | |
| 163 | +`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`, | |
| 164 | +`in 2026`, `released this year|last year`, `last N days|weeks|months`, `by <Org>|from <Org>|<Org> models…` (verified against organization names | |
| 165 | +and aliases — an unknown name is demoted to free text and listed in `unrecognised`), `over|≥|more than 100B`, `under 30B`, `between 7B and 70B`, | |
| 166 | +`apache|mit|bsd|cc-by|gpl|llama|gemma license`, `commercial use`, `papers …`, `benchmark <name>`, `provider <name>`, sort hints `cheapest|newest|largest|smallest|best`. | |
| 167 | +`memory_gb` becomes an ESTIMATED parameter bound (4-bit, 8K context) — noted in `query.note`. All numeric casts in SQL are regex-guarded. | |
| 168 | + | |
| 169 | +## Semantics changes (1.1) | |
| 170 | + | |
| 171 | +1. **Models universe.** `/models`, `/hardware/fit`, `/open`, `/run-locally`, leaderboards, matrix, price index and `/stats.entities.model` count | |
| 172 | + **canonical model releases**: `entity_type = 'model' and merged_into is null`. Artifacts (`entity_type = 'artifact'`: checkpoints, quantisations, | |
| 173 | + conversions, packagings) and folded evaluation variants (rows with `merged_into`) are excluded. `include=artifacts` on `/models` restores the | |
| 174 | + pre-1.1 universe. Every old slug keeps resolving: `/models/<artifact slug>` returns the artifact detail (`canonical`, `artifact_kind`); | |
| 175 | + `/models/<folded variant slug>` returns the canonical model with `redirected_from`. | |
| 176 | +2. **Event date fields.** `/changes`, `/changes/daily`, `/changes/categories`, `/timeline`, `/entities/{slug}/timeline`, `/diff`, `/pulse`, | |
| 177 | + `/trending?kind≠views` use `occurred_at = coalesce(effective_at, observed_at)` and exclude `is_backfill = true` rows. `include_backfill=1` and | |
| 178 | + `date_field=observed` restore the v1 behaviour; cursors follow the chosen field. `/stats.change_events_24h` keeps the v1 meaning | |
| 179 | + (observed) and `change_events_live_24h` is the new "what happened today" counter. | |
| 180 | +3. **Leaderboard grouping.** Results are organised in comparability groups (canonical metric × `config_key`, the hash of task-defining | |
| 181 | + configuration keys). `/benchmarks` `leader`/`top`, `/benchmarks/{slug}/leaderboard`, `/benchmarks/matrix`, `/compare` benchmark dimensions, | |
| 182 | + `/pareto`, `/frontier` and `/find-a-model` ranks are computed per group with one row per canonical model. `/benchmarks/{slug}/results` keeps the | |
| 183 | + v1 one-row-per-result listing. Rows written before migration 0003 (NULL `config_key`/`trust_level`) are recomputed on read from `config` and | |
| 184 | + the source key, so the API behaves identically before and after canonicalisation. | |
| 185 | + | |
| 186 | +Also: `/companies.total == /stats.organizations_total`; `/benchmarks/{slug}` resolves aliases (`GPQA Diamond`); `/entities/{slug}/related` | |
| 187 | +uses `family_id` when present; `x-forwarded-for` is honoured only when `settings.trust_proxy` / `AIA_TRUST_PROXY=1`. | |
| 188 | + | |
| 189 | +## Deprecations | |
| 190 | + | |
| 191 | +None. No v1 route, parameter, field or type was removed or retyped in 1.1. | |
| 192 | + | |
| 193 | +## 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: '<METHOD> <path>', target, payload: { method, path, query?, body? }, ip }`) | |
| 101 | 194 | |
| 102 | 195 | | route | returns / action | |
| 103 | 196 | |---|---| |
| 104 | −| `GET /admin/overview` | `{ stats, queue: Record<kind, Record<status, n>>, heartbeats, connectors: { ok, degraded, failing, disabled }, review_pending, recent_errors: n, llm: { jobs_24h, tokens_24h, by_stage } , archive }` | | |
| 105 | −| `GET /admin/connectors` | full `connectors` rows + last run + docs/snapshots counts + source tier | | |
| 106 | −| `POST /admin/connectors/{name}/run` `{ force?: bool }` | enqueue an immediate run (Redis flag `aia:run-now:<name>` consumed by the scheduler tick) → `{ queued: true }` | | |
| 107 | −| `PATCH /admin/connectors/{name}` `{ enabled?, interval_seconds?, priority? }` | update | | |
| 108 | −| `GET /admin/runs?connector=&limit=` · `GET /admin/errors?connector=&limit=` | rows | | |
| 109 | −| `GET /admin/documents?connector=&status=&q=&limit=&offset=` · `GET /admin/documents/{id}` | documents (+ snapshots list); `GET /admin/snapshots/{id}` → metadata + `structured` + `diff` + first 20 kB of cleaned text (never raw HTML) | | |
| 110 | −| `GET /admin/jobs?status=&kind=` · `POST /admin/jobs/{id}/retry` · `POST /admin/jobs/requeue-dead` | queue | | |
| 111 | −| `GET /admin/llm-jobs?limit=` · `GET /admin/llm/health` | LLM factory accounting | | |
| 112 | −| `GET /admin/review?status=pending&kind=` · `POST /admin/review/{id}` `{ action: "approve"|"reject"|"edit", resolution?: object }` | review queue; approving a `merge_candidate` calls `merge_entities(source, target)` (aliases/identifiers/claims/relations/events moved, `merged_into` set) | | |
| 113 | −| `GET /admin/entities/duplicates?type=&limit=` | candidate pairs by normalized name similarity (`pg_trgm`) within a type | | |
| 114 | −| `POST /admin/entities/merge` `{ source_id, target_id }` · `POST /admin/entities/{id}/claims/{claim_id}/retract` | curation | | |
| 115 | −| `POST /admin/reprocess` `{ connector, url? }` | enqueue `reprocess_snapshot` | | |
| 116 | −| `POST /admin/llm/enqueue` `{ limit?, task? }` | queue `llm_extract` for pending snapshots | | |
| 117 | −| `GET /admin/infrastructure` | heartbeats, archive size, DB size (`pg_database_size`), table sizes, node hostname, uptime | | |
| 118 | −| `POST /admin/cache/flush` | `{ flushed: n }` | | |
| 119 | −| `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | maintenance | | |
| 120 | − | |
| 121 | −## Implementation notes (2026-09-11, `src/aiatlas/api`) | |
| 122 | − | |
| 123 | −Deviations and additions relative to the table above — everything else is implemented as written. | |
| 124 | − | |
| 125 | −- **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds (`limit=999`, non-integer `depth`…) return **422** with | |
| 126 | − `{ detail: "<field>: <message>", errors: [...] }`; semantic errors raised by the routes (bad `sort`, bad date, wrong `scope`, `ids` count) return **400**. | |
| 127 | −- **`/companies`** (listing, alias, `/compare` type `company`) covers the organization-like types `company | organization | lab | university`. | |
| 128 | − `/frameworks/{slug}` also accepts `library | runtime`; `/tools/{slug}` accepts `tool | agent | application | product | mcp_server`. | |
| 129 | −- **`/explore/{type}`** accepts plural forms (`/explore/models`, `/explore/companies`) and adds `entity_type` + `label` to the page. | |
| 130 | −- **`EntitySummary.description`** is truncated at 280 characters in summaries (full text in `EntityDetail`). | |
| 131 | −- **Cursor feeds** (`/changes`, `/entities/{slug}/timeline`) add `next_before` (the `observed_at` of the last item, or `null` at the end). | |
| 132 | − `/changes` also honours `offset` and `entity=` for convenience; `total` is capped at 10 000. | |
| 133 | −- **`/changes/daily`** adds `total`, `labels`, `previous_day`, `next_day`; sections are capped at `per_section` (default 30) items each. | |
| 134 | −- **`/timeline`** month groups carry `count`; the response includes `total` (events returned). | |
| 135 | −- **`/search`**: `query` carries `semantic: bool` (embedding used or not). When the embedding endpoint times out (2 s) the API backs off to | |
| 136 | − FTS-only for 5 minutes (`aia:api:search:embed-degraded`) so the LLM never adds latency to consecutive queries. | |
| 137 | −- **`hardware_fit`** (model detail) is *omitted* when `parameter_count` is unknown (nothing is estimated from thin air); when present the | |
| 138 | − detail also carries `hardware_fit_assumptions: string[]`. `/hardware/fit` adds `estimated: true`, `counts`, optional `openness=` filter. | |
| 139 | −- **`/prices/index`** series rows add `max_input` and `offers`; response adds `days` and `note`. `/prices/history` requires `model` or `provider`. | |
| 140 | −- **`/benchmarks/{slug}/results`** and `/history` include the `benchmark` summary; result rows include `valid_to`. | |
| 141 | −- **`/diff`** adds `scope` (resolved description) and extra `counts` (`events`, `price_rows_opened/closed`, `claims_superseded`, `entities_at_a/b`). | |
| 142 | −- **`/sources`** items add `snapshots`, `claims`, `base_url`, `robots_policy`, `priority`, `notes`; response adds `tiers` legend. | |
| 143 | −- **`/methodology`** adds `quality_version`, `expected_fields`, `principles`; `event_types` are live counts from `change_events`. | |
| 144 | −- **`/trending`** accepts `type=`; `/sitemap` `limit` ≤ 5000 (the one listing not capped at 200). | |
| 145 | −- **Rate limits** are in-process sliding windows (search 60/min, views 1/s, admin 240/min per IP) — fine for the single uvicorn worker behind Next.js. | |
| 146 | −- **Admin**: `GET /admin/review?status=all` lists every status; approving a `conflict` with `resolution.keep_claim_id` promotes that claim; | |
| 147 | − `PATCH /admin/connectors/{name}` also resets `health`/`circuit_open_until` when toggling; `/admin/connectors` flags `run_now_pending` and | |
| 148 | − connectors present in code but not in the table; `/admin/snapshots/{id}` never returns `raw_path`/`text_path` (`has_raw`/`has_text` booleans instead). | |
| 149 | −- **Migration `0002_api_indexes`** adds partial indexes for the read paths (type + release_date/family/openness/updated/first_seen, importance-ordered | |
| 150 | − events, current leaderboards/prices, page views, review kinds). | |
| 197 | +| `GET /admin/overview` · `GET /admin/connectors` · `POST /admin/connectors/{name}/run` · `PATCH /admin/connectors/{name}` · `GET /admin/runs` · `GET /admin/errors` | v1 (unchanged) | | |
| 198 | +| `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) | | |
| 199 | +| `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 | | |
| 200 | +| `GET /admin/infrastructure` · `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | v1 (unchanged) | | |
| 201 | +| `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). | | |
| 202 | +| **`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) | | |
| 203 | +| **`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` | | |
| 204 | +| **`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=<decision>)` 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 }` | | |
| 205 | +| **`GET /admin/anomalies?status=open|all&severity=&check=&limit=&offset=`** · **`POST /admin/anomalies/{id}`** `{ status: resolved|ignored|open, note? }` | anomaly flags (never deleted) | | |
| 206 | +| **`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 | | |
| 207 | +| **`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 | | |
| 208 | +| **`GET /admin/audit?limit=&offset=&action=`** | `{ items: { id, actor, action, target, payload, ip, created_at }[], total }` | | |
| 209 | +| **`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 }` | | |
| 210 | + | |
| 211 | +## Implementation notes (`src/aiatlas/api`) | |
| 212 | + | |
| 213 | +- **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds return **422** with `{ detail, errors }`; semantic errors raised by the routes return **400**. | |
| 214 | +- **Caching**: public GETs are cached in Redis (`aia:api:<path>?<sorted query>`, 60–600 s) 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`. | |
| 215 | +- **Connections**: `EntityDetail` runs its blocks in ≤ 4 concurrent groups (one pooled connection each, sequential inside a group) instead of one connection per block. | |
| 216 | +- **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. | |
| 217 | +- **Search**: `query.semantic` says whether an embedding was used; on embedding timeout (2 s) the API backs off to FTS-only for 5 minutes. | |
| 218 | +- **`hardware_fit`** (model detail) is omitted when `parameter_count` is unknown; every fit payload carries `estimated: true`. | |
| 219 | +- **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. | |
| 220 | +- **Migrations**: `0002_api_indexes` (read paths), `0003_canonical_ontology` (hierarchy, event semantics, comparability, admin tables) — both required by 1.1. | |
modified
src/aiatlas/api/common.py
+151 −32
@@ -5,6 +5,8 @@ from __future__ import annotations | ||
| 5 | 5 | |
| 6 | 6 | import hmac |
| 7 | 7 | import inspect |
| 8 | +import logging | |
| 9 | +import os | |
| 8 | 10 | import time |
| 9 | 11 | from collections import defaultdict, deque |
| 10 | 12 | from collections.abc import Awaitable, Callable |
@@ -20,9 +22,11 @@ from fastapi.responses import JSONResponse | ||
| 20 | 22 | from sqlalchemy.ext.asyncio import AsyncConnection |
| 21 | 23 | |
| 22 | 24 | from aiatlas.config import settings |
| 23 | −from aiatlas.db import fetch_one | |
| 25 | +from aiatlas.db import execute, fetch_one, jsonb, transaction | |
| 24 | 26 | from aiatlas.services import cache |
| 25 | 27 | |
| 28 | +log = logging.getLogger("aiatlas.api") | |
| 29 | + | |
| 26 | 30 | # ------------------------------------------------------------------------------------------------------------------ errors & JSON |
| 27 | 31 | |
| 28 | 32 | |
@@ -167,48 +171,84 @@ def cached(ttl_s: int) -> Callable[[Callable[..., Awaitable[Any]]], Callable[... | ||
| 167 | 171 | return deco |
| 168 | 172 | |
| 169 | 173 | |
| 170 | −# ------------------------------------------------------------------------------------------------------------------ admin auth | |
| 171 | − | |
| 172 | − | |
| 173 | −def require_admin(request: Request) -> None: | |
| 174 | − expected = settings.admin_token | |
| 175 | − if not expected: | |
| 176 | − raise ApiError(503, "admin API disabled: AIA_ADMIN_TOKEN is not configured") | |
| 177 | − token = request.headers.get("x-aia-admin-token") or "" | |
| 178 | − if not hmac.compare_digest(token, expected): | |
| 179 | − raise ApiError(401, "invalid or missing x-aia-admin-token") | |
| 180 | − | |
| 181 | − | |
| 182 | 174 | # ------------------------------------------------------------------------------------------------------------------ rate limiting |
| 183 | 175 | |
| 184 | −_LIMITS: dict[str, tuple[int, float]] = {"search": (60, 60.0), "views": (1, 1.0), "admin": (240, 60.0), "default": (600, 60.0)} | |
| 176 | +_LIMITS: dict[str, tuple[int, float]] = {"search": (60, 60.0), "views": (1, 1.0), "admin": (240, 60.0), "admin-auth-failed": (10, 60.0), "default": (600, 60.0)} | |
| 185 | 177 | _hits: dict[tuple[str, str], deque[float]] = defaultdict(deque) |
| 186 | 178 | |
| 187 | 179 | |
| 180 | +def trust_proxy() -> bool: | |
| 181 | + """`x-forwarded-for` is only honoured behind a trusted reverse proxy (`settings.trust_proxy` or env `AIA_TRUST_PROXY=1`).""" | |
| 182 | + v = getattr(settings, "trust_proxy", None) | |
| 183 | + if v is None: | |
| 184 | + v = os.environ.get("AIA_TRUST_PROXY", "") | |
| 185 | + return str(v).strip().lower() in ("1", "true", "yes", "on") | |
| 186 | + | |
| 187 | + | |
| 188 | 188 | def client_ip(request: Request) -> str: |
| 189 | − fwd = request.headers.get("x-forwarded-for") | |
| 190 | − if fwd: | |
| 191 | − return fwd.split(",")[0].strip() | |
| 189 | + if trust_proxy(): | |
| 190 | + fwd = request.headers.get("x-forwarded-for") | |
| 191 | + if fwd: | |
| 192 | + return fwd.split(",")[0].strip() | |
| 192 | 193 | return request.client.host if request.client else "unknown" |
| 193 | 194 | |
| 194 | 195 | |
| 196 | +def _bucket_hit(ip: str, bucket: str, *, record: bool = True) -> bool: | |
| 197 | + """Sliding-window counter. Returns True when the call is allowed (and records it), False when the bucket is full.""" | |
| 198 | + n, window = _LIMITS.get(bucket, _LIMITS["default"]) | |
| 199 | + now = time.monotonic() | |
| 200 | + q = _hits[(ip, bucket)] | |
| 201 | + while q and q[0] <= now - window: | |
| 202 | + q.popleft() | |
| 203 | + if len(q) >= n: | |
| 204 | + return False | |
| 205 | + if record: | |
| 206 | + q.append(now) | |
| 207 | + if len(_hits) > 20000: | |
| 208 | + for k in [k for k, v in _hits.items() if not v or v[-1] < now - 120]: | |
| 209 | + _hits.pop(k, None) | |
| 210 | + return True | |
| 211 | + | |
| 212 | + | |
| 195 | 213 | def rate_limit(bucket: str) -> Callable[[Request], None]: |
| 196 | 214 | def dep(request: Request) -> None: |
| 197 | 215 | n, window = _LIMITS.get(bucket, _LIMITS["default"]) |
| 198 | − now = time.monotonic() | |
| 199 | − q = _hits[(client_ip(request), bucket)] | |
| 200 | − while q and q[0] <= now - window: | |
| 201 | − q.popleft() | |
| 202 | − if len(q) >= n: | |
| 216 | + if not _bucket_hit(client_ip(request), bucket): | |
| 203 | 217 | raise ApiError(429, f"rate limit exceeded for {bucket}: {n} per {int(window)} s") |
| 204 | − q.append(now) | |
| 205 | − if len(_hits) > 20000: | |
| 206 | − for k in [k for k, v in _hits.items() if not v or v[-1] < now - 120]: | |
| 207 | − _hits.pop(k, None) | |
| 208 | 218 | |
| 209 | 219 | return dep |
| 210 | 220 | |
| 211 | 221 | |
| 222 | +# ------------------------------------------------------------------------------------------------------------------ admin auth | |
| 223 | + | |
| 224 | + | |
| 225 | +def require_admin(request: Request) -> None: | |
| 226 | + """Token check. Failed attempts are counted per IP (10/min → 429) so the token cannot be brute-forced; must run AFTER `rate_limit('admin')`.""" | |
| 227 | + ip = client_ip(request) | |
| 228 | + if not _bucket_hit(ip, "admin-auth-failed", record=False): | |
| 229 | + raise ApiError(429, "too many failed admin authentications; retry in a minute") | |
| 230 | + expected = settings.admin_token | |
| 231 | + if not expected: | |
| 232 | + raise ApiError(503, "admin API disabled: AIA_ADMIN_TOKEN is not configured") | |
| 233 | + token = request.headers.get("x-aia-admin-token") or "" | |
| 234 | + if not hmac.compare_digest(token, expected): | |
| 235 | + _bucket_hit(ip, "admin-auth-failed") | |
| 236 | + raise ApiError(401, "invalid or missing x-aia-admin-token") | |
| 237 | + | |
| 238 | + | |
| 239 | +ADMIN_DEPENDENCIES = [Depends(rate_limit("admin")), Depends(require_admin)] # order matters: limit first, then auth | |
| 240 | + | |
| 241 | + | |
| 242 | +async def audit(action: str, target: str | None = None, payload: dict[str, Any] | None = None, ip: str | None = None, *, actor: str = "admin") -> None: | |
| 243 | + """One row in `admin_audit_log` per admin action (best effort — never breaks the request).""" | |
| 244 | + try: | |
| 245 | + async with transaction() as conn: | |
| 246 | + await execute(conn, "insert into admin_audit_log (actor, action, target, payload, ip) values (:a, :ac, :t, cast(:p as jsonb), :ip)", | |
| 247 | + a=actor, ac=action, t=target, p=jsonb(payload or {}), ip=ip) | |
| 248 | + except Exception: # noqa: BLE001 | |
| 249 | + log.warning("audit log write failed", extra={"action": action, "target": target}) | |
| 250 | + | |
| 251 | + | |
| 212 | 252 | # ------------------------------------------------------------------------------------------------------------------ entity serialisers |
| 213 | 253 | |
| 214 | 254 | ENTITY_FIELDS = ("id", "entity_type", "canonical_name", "slug", "description", "status", "organization_id", "attributes", "quality", "counts", |
@@ -249,7 +289,36 @@ COMPANY_TYPES = ("company", "organization", "lab", "university") | ||
| 249 | 289 | TYPE_LABELS = {"model": "Models", "company": "Companies", "organization": "Organizations", "lab": "Labs", "university": "Universities", |
| 250 | 290 | "provider": "Providers", "paper": "Papers", "benchmark": "Benchmarks", "hardware": "Hardware", "framework": "Frameworks", |
| 251 | 291 | "dataset": "Datasets", "tool": "Tools", "repository": "Repositories", "regulation": "Regulation", "incident": "Incidents", |
| 252 | − "researcher": "Researchers", "agent": "Agents", "product": "Products", "runtime": "Runtimes", "conference": "Conferences"} | |
| 292 | + "researcher": "Researchers", "agent": "Agents", "product": "Products", "runtime": "Runtimes", "conference": "Conferences", | |
| 293 | + "artifact": "Artifacts", "model_family": "Model families", "license": "Licenses"} | |
| 294 | + | |
| 295 | +# Canonical model universe (API 1.1): model releases only — artifacts (checkpoints, quantisations, conversions) are `entity_type = 'artifact'`, | |
| 296 | +# folded evaluation variants carry `merged_into`. `include=artifacts` on /models restores the pre-1.1 universe. | |
| 297 | +MODEL_UNIVERSE = "e.entity_type = 'model' and e.merged_into is null" | |
| 298 | +MODEL_OR_ARTIFACT_UNIVERSE = "e.entity_type in ('model', 'artifact') and e.merged_into is null" | |
| 299 | +OPEN_CATEGORIES = ("open-weights", "open-source") # "open-*" | |
| 300 | +DOWNLOADABLE_CATEGORIES = ("open-weights", "open-source", "restricted-weights", "restricted") # weights can be downloaded (terms may restrict) | |
| 301 | +ARTIFACT_KINDS = ("checkpoint", "quantization", "conversion", "packaging") | |
| 302 | + | |
| 303 | + | |
| 304 | +def openness_values(raw: str | None) -> list[str]: | |
| 305 | + """Expand the `openness=` filter vocabulary: `open` → open-weights + open-source (+ legacy `open`); `restricted` ↔ `restricted-weights`.""" | |
| 306 | + vals = [v.strip() for v in (raw or "").split(",") if v.strip()] | |
| 307 | + out: list[str] = [] | |
| 308 | + for v in vals: | |
| 309 | + if v == "open": | |
| 310 | + out += [*OPEN_CATEGORIES, "open"] | |
| 311 | + elif v in ("restricted", "restricted-weights"): | |
| 312 | + out += ["restricted", "restricted-weights"] | |
| 313 | + elif v == "downloadable": | |
| 314 | + out += list(DOWNLOADABLE_CATEGORIES) | |
| 315 | + else: | |
| 316 | + out.append(v) | |
| 317 | + return list(dict.fromkeys(out)) | |
| 318 | + | |
| 319 | + | |
| 320 | +def is_open(openness: Any) -> bool: | |
| 321 | + return str(openness or "") in OPEN_CATEGORIES | |
| 253 | 322 | |
| 254 | 323 | |
| 255 | 324 | def summary_attributes(entity_type: str, attrs: dict[str, Any] | None) -> dict[str, Any]: |
@@ -371,6 +440,24 @@ PRICE_COLS = ("p.id, p.provider_model_id, p.input_per_mtok, p.output_per_mtok, p | ||
| 371 | 440 | "p.valid_to, p.source_url, p.tier, " + entity_cols("m", "m_") + ", " + entity_cols("pv", "p_")) |
| 372 | 441 | PRICE_FROM = "prices p " + entity_join("m", "p.model_id") + " " + entity_join("pv", "p.provider_id") |
| 373 | 442 | |
| 443 | +_NATIVE_UNIT_SUFFIXES = ("_per_mtok", "_per_1k_requests", "_per_mtok_hour", "_per_image", "_per_request", "_per_second", "_per_minute", "_per_hour", "_per_char") | |
| 444 | + | |
| 445 | + | |
| 446 | +def deployment_row(row: dict[str, Any]) -> dict[str, Any]: | |
| 447 | + """`Deployment` (API 1.1): one model × provider × provider_model_id offer. Prices in USD per 1M tokens unless the key says otherwise; | |
| 448 | + provider-specific priced features (`flex_input_per_mtok`, `search_grounding_per_1k_requests`…) are kept verbatim under `native_units`.""" | |
| 449 | + feats = dict(row.get("features") or {}) | |
| 450 | + native = {k: v for k, v in feats.items() if any(k.endswith(s) for s in _NATIVE_UNIT_SUFFIXES) or k == "per_request"} | |
| 451 | + other = {k: v for k, v in feats.items() if k not in native} | |
| 452 | + return {"id": row["id"], "model": entity_summary(row, "m_"), "provider": entity_summary(row, "p_"), "provider_model_id": row.get("provider_model_id"), | |
| 453 | + "context_length": row.get("context_length"), "max_output_tokens": row.get("max_output_tokens"), | |
| 454 | + "prices": {"input": row.get("input_per_mtok"), "cached_input": row.get("cached_input_per_mtok"), "cache_write": row.get("cache_write_per_mtok"), | |
| 455 | + "output": row.get("output_per_mtok"), "batch_input": row.get("batch_input_per_mtok"), "batch_output": row.get("batch_output_per_mtok"), | |
| 456 | + "per_image": row.get("per_image"), "per_request": row.get("per_request"), "currency": row.get("currency") or "USD", "unit": "USD per 1M tokens", | |
| 457 | + "native_units": native}, | |
| 458 | + "features": other, "status": "active" if row.get("valid_to") is None else "delisted", "observed_at": row.get("observed_at"), | |
| 459 | + "valid_from": row.get("valid_from"), "valid_to": row.get("valid_to"), "source_url": row.get("source_url"), "tier": row.get("tier")} | |
| 460 | + | |
| 374 | 461 | |
| 375 | 462 | def result_row(row: dict[str, Any]) -> dict[str, Any]: |
| 376 | 463 | return {"id": row["id"], "model": entity_summary(row, "m_"), "benchmark": entity_summary(row, "b_"), "score": row.get("score"), "metric": row.get("metric"), |
@@ -400,24 +487,44 @@ CLAIM_FROM = "claims c left join sources s on s.id = c.source_id" | ||
| 400 | 487 | # ------------------------------------------------------------------------------------------------------------------ entity resolution |
| 401 | 488 | |
| 402 | 489 | |
| 403 | −async def resolve_entity(conn: AsyncConnection, slug_or_id: str, types: tuple[str, ...] | None = None) -> dict[str, Any]: | |
| 404 | − """Slug first, then id; follows `merged_into`; 404 when absent or when the type does not match the mounted alias.""" | |
| 490 | +ENTITY_EXTRA_COLS = "e.provenance, e.merged_into, e.family_id, e.canonical_id, e.artifact_kind, e.identity_confidence" | |
| 491 | + | |
| 492 | + | |
| 493 | +async def resolve_entity(conn: AsyncConnection, slug_or_id: str, types: tuple[str, ...] | None = None, *, aliases: bool = False) -> dict[str, Any]: | |
| 494 | + """Slug first, then id (then alias when `aliases=True`); follows `merged_into` and records the hop in `redirected_from` | |
| 495 | + (`{slug, id}` of the row the caller asked for) so the web layer can 301; 404 when absent or when the type does not match the mounted alias.""" | |
| 405 | 496 | key = slug_or_id.strip() |
| 406 | 497 | if not key or len(key) > 200: |
| 407 | 498 | raise ApiError(404, "entity not found") |
| 408 | − row = await fetch_one(conn, f"select {ENTITY_COLS}, e.provenance, e.merged_into from {ENTITY_FROM} where e.slug = :k or e.id = :k " | |
| 409 | − f"order by case when e.slug = :k then 0 else 1 end limit 1", k=key) | |
| 499 | + sel = f"select {ENTITY_COLS}, {ENTITY_EXTRA_COLS} from {ENTITY_FROM}" | |
| 500 | + row = await fetch_one(conn, f"{sel} where e.slug = :k or e.id = :k order by case when e.slug = :k then 0 else 1 end limit 1", k=key) | |
| 501 | + if not row and aliases: | |
| 502 | + row = await fetch_one(conn, f"{sel} where e.id = (select a.entity_id from entity_aliases a join entities x on x.id = a.entity_id " | |
| 503 | + f"where (a.alias_norm = :n or lower(a.alias) = lower(:k)) {'and x.entity_type = any(cast(:types as text[]))' if types else ''} " | |
| 504 | + f"order by x.merged_into is not null, x.updated_at desc limit 1)", k=key, n=_alias_norm(key), types=list(types or ())) | |
| 505 | + asked = row | |
| 410 | 506 | hops = 0 |
| 411 | 507 | while row and row.get("merged_into") and hops < 5: |
| 412 | − row = await fetch_one(conn, f"select {ENTITY_COLS}, e.provenance, e.merged_into from {ENTITY_FROM} where e.id = :k", k=row["merged_into"]) | |
| 508 | + row = await fetch_one(conn, f"{sel} where e.id = :k", k=row["merged_into"]) | |
| 413 | 509 | hops += 1 |
| 414 | 510 | if not row: |
| 415 | 511 | raise ApiError(404, "entity not found") |
| 416 | 512 | if types and row["entity_type"] not in types: |
| 417 | 513 | raise ApiError(404, f"entity {key!r} is a {row['entity_type']}, not one of {', '.join(types)}") |
| 514 | + if asked is not None and asked["id"] != row["id"]: | |
| 515 | + row["redirected_from"] = {"slug": asked["slug"], "id": asked["id"], "entity_type": asked["entity_type"]} | |
| 418 | 516 | return row |
| 419 | 517 | |
| 420 | 518 | |
| 519 | +def _alias_norm(s: str) -> str: | |
| 520 | + try: | |
| 521 | + from aiatlas.ids import normalize_alias | |
| 522 | + | |
| 523 | + return normalize_alias(s) | |
| 524 | + except Exception: # noqa: BLE001 | |
| 525 | + return "".join(ch for ch in s.lower() if ch.isalnum()) | |
| 526 | + | |
| 527 | + | |
| 421 | 528 | async def resolve_id(conn: AsyncConnection, slug_or_id: str | None, types: tuple[str, ...] | None = None) -> str | None: |
| 422 | 529 | if not slug_or_id: |
| 423 | 530 | return None |
@@ -425,15 +532,22 @@ async def resolve_id(conn: AsyncConnection, slug_or_id: str | None, types: tuple | ||
| 425 | 532 | |
| 426 | 533 | |
| 427 | 534 | __all__ = [ |
| 535 | + "ADMIN_DEPENDENCIES", | |
| 536 | + "ARTIFACT_KINDS", | |
| 428 | 537 | "CLAIM_COLS", |
| 429 | 538 | "CLAIM_FROM", |
| 430 | 539 | "COMPANY_TYPES", |
| 540 | + "DOWNLOADABLE_CATEGORIES", | |
| 431 | 541 | "ENTITY_COLS", |
| 542 | + "ENTITY_EXTRA_COLS", | |
| 432 | 543 | "ENTITY_FROM", |
| 433 | 544 | "EVENT_COLS", |
| 434 | 545 | "EVENT_FROM", |
| 435 | 546 | "EVENT_TYPE_LABELS", |
| 436 | 547 | "MAIN_ENTITY_TYPES", |
| 548 | + "MODEL_OR_ARTIFACT_UNIVERSE", | |
| 549 | + "MODEL_UNIVERSE", | |
| 550 | + "OPEN_CATEGORIES", | |
| 437 | 551 | "PAGINATION", |
| 438 | 552 | "PRICE_COLS", |
| 439 | 553 | "PRICE_FROM", |
@@ -446,6 +560,7 @@ __all__ = [ | ||
| 446 | 560 | "AtlasJSONResponse", |
| 447 | 561 | "Pagination", |
| 448 | 562 | "attr_num", |
| 563 | + "audit", | |
| 449 | 564 | "cache_key", |
| 450 | 565 | "cached", |
| 451 | 566 | "change_event", |
@@ -453,6 +568,7 @@ __all__ = [ | ||
| 453 | 568 | "client_ip", |
| 454 | 569 | "csv", |
| 455 | 570 | "day_bounds", |
| 571 | + "deployment_row", | |
| 456 | 572 | "dumps", |
| 457 | 573 | "enrich_provenance", |
| 458 | 574 | "entity_cols", |
@@ -461,9 +577,11 @@ __all__ = [ | ||
| 461 | 577 | "event_type_importance", |
| 462 | 578 | "event_type_label", |
| 463 | 579 | "flip_order", |
| 580 | + "is_open", | |
| 464 | 581 | "normalize", |
| 465 | 582 | "normalize_status", |
| 466 | 583 | "num_expr", |
| 584 | + "openness_values", | |
| 467 | 585 | "org_of", |
| 468 | 586 | "page", |
| 469 | 587 | "parse_date", |
@@ -474,4 +592,5 @@ __all__ = [ | ||
| 474 | 592 | "resolve_entity", |
| 475 | 593 | "resolve_id", |
| 476 | 594 | "result_row", |
| 595 | + "trust_proxy", | |
| 477 | 596 | ] |
modified
src/aiatlas/api/detail.py
+274 −40
@@ -1,12 +1,20 @@ | ||
| 1 | −"""`EntityDetail` builder (docs/API.md): shared by `/entities/{slug}` and the type-scoped aliases.""" | |
| 1 | +"""`EntityDetail` builder (docs/API.md): shared by `/entities/{slug}` and the type-scoped aliases. | |
| 2 | + | |
| 3 | +API 1.1: blocks run in at most FOUR concurrent groups (one pooled connection each, sequential inside a group) instead of one connection per | |
| 4 | +block; model details gain `family`, `artifacts`, `deployments`, `identity`, `licence`, `openness`, `version_history` and grouped `benchmarks`; | |
| 5 | +artifact details carry `canonical` + `artifact_kind`; a resolved `merged_into` hop is reported as `redirected_from`.""" | |
| 2 | 6 | from __future__ import annotations |
| 3 | 7 | |
| 4 | 8 | import asyncio |
| 9 | +from collections import defaultdict | |
| 5 | 10 | from typing import Any |
| 6 | 11 | |
| 7 | 12 | from sqlalchemy.ext.asyncio import AsyncConnection |
| 8 | 13 | |
| 9 | 14 | from aiatlas.api.common import ( |
| 15 | + ARTIFACT_KINDS, | |
| 16 | + CLAIM_COLS, | |
| 17 | + CLAIM_FROM, | |
| 10 | 18 | COMPANY_TYPES, |
| 11 | 19 | ENTITY_COLS, |
| 12 | 20 | ENTITY_FROM, |
@@ -18,16 +26,23 @@ from aiatlas.api.common import ( | ||
| 18 | 26 | RESULT_FROM, |
| 19 | 27 | RESULT_ORDER, |
| 20 | 28 | change_event, |
| 29 | + deployment_row, | |
| 21 | 30 | enrich_provenance, |
| 22 | 31 | entity_summary, |
| 23 | 32 | price_row, |
| 24 | 33 | result_row, |
| 25 | 34 | ) |
| 26 | −from aiatlas.db import connection, fetch_all | |
| 35 | +from aiatlas.db import connection, fetch_all, fetch_one | |
| 36 | +from aiatlas.ontology.benchmarks import TRUST_LABELS | |
| 37 | +from aiatlas.ontology.licenses import LICENSES, normalize_license | |
| 38 | +from aiatlas.ontology.openness import OPENNESS_DEFINITIONS, OPENNESS_LABELS, normalize_openness, openness_dimensions | |
| 27 | 39 | from aiatlas.services import hardware_fit as hf |
| 40 | +from aiatlas.services.frontier import config_summary, enrich, group_label | |
| 28 | 41 | |
| 29 | 42 | LINEAGE_PREDICATES = ("derived_from", "fine_tuned_from", "distilled_from", "merged_from", "quantized_from") |
| 30 | 43 | RELATION_GROUP_LIMIT = 24 |
| 44 | +VERSIONED_PROPERTIES = ("context_length", "max_output_tokens", "status", "knowledge_cutoff", "license", "openness", "parameter_count") | |
| 45 | +MAX_CONCURRENT_GROUPS = 4 | |
| 31 | 46 | |
| 32 | 47 | |
| 33 | 48 | async def relations_grouped(conn: AsyncConnection, entity_id: str) -> list[dict[str, Any]]: |
@@ -68,17 +83,30 @@ def _scope_sql(is_org: bool) -> str: | ||
| 68 | 83 | return "ev.entity_id = :id" |
| 69 | 84 | |
| 70 | 85 | |
| 86 | +def event_date_col(date_field: str) -> str: | |
| 87 | + return "ev.observed_at" if date_field == "observed" else "ev.occurred_at" | |
| 88 | + | |
| 89 | + | |
| 71 | 90 | async def timeline_of(conn: AsyncConnection, entity_id: str, entity_type: str, *, limit: int = 30, before: Any = None, |
| 72 | − include_documents: bool = False) -> list[dict[str, Any]]: | |
| 91 | + include_documents: bool = False, include_backfill: bool = False, date_field: str = "occurred") -> list[dict[str, Any]]: | |
| 92 | + col = event_date_col(date_field) | |
| 73 | 93 | where = [_scope_sql(entity_type in COMPANY_TYPES)] |
| 74 | 94 | params: dict[str, Any] = {"id": entity_id, "lim": limit} |
| 75 | 95 | if before is not None: |
| 76 | − where.append("ev.observed_at < :before") | |
| 96 | + where.append(f"{col} < :before") | |
| 77 | 97 | params["before"] = before |
| 78 | 98 | if not include_documents: |
| 79 | 99 | where.append("ev.event_type <> 'DOCUMENT_CHANGED'") |
| 80 | − rows = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where {' and '.join(where)} order by ev.observed_at desc, ev.id desc limit :lim", **params) | |
| 81 | − return [change_event(r) for r in rows] | |
| 100 | + if not include_backfill: | |
| 101 | + where.append("ev.is_backfill = false") | |
| 102 | + rows = await fetch_all(conn, f"select {EVENT_COLS}, ev.occurred_at, ev.is_backfill, ev.group_key from {EVENT_FROM} where {' and '.join(where)} " | |
| 103 | + f"order by {col} desc, ev.id desc limit :lim", **params) | |
| 104 | + out = [] | |
| 105 | + for r in rows: | |
| 106 | + ev = change_event(r) | |
| 107 | + ev["occurred_at"], ev["is_backfill"], ev["group_key"] = r.get("occurred_at"), r.get("is_backfill"), r.get("group_key") | |
| 108 | + out.append(ev) | |
| 109 | + return out | |
| 82 | 110 | |
| 83 | 111 | |
| 84 | 112 | async def prices_of_model(conn: AsyncConnection, model_id: str, *, current_only: bool) -> list[dict[str, Any]]: |
@@ -88,6 +116,11 @@ async def prices_of_model(conn: AsyncConnection, model_id: str, *, current_only: | ||
| 88 | 116 | return [price_row(r) for r in rows] |
| 89 | 117 | |
| 90 | 118 | |
| 119 | +async def deployments_of_model(conn: AsyncConnection, model_id: str) -> list[dict[str, Any]]: | |
| 120 | + rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.model_id = :id and p.valid_to is null order by p.output_per_mtok nulls last, pv.canonical_name limit 200", id=model_id) | |
| 121 | + return [deployment_row(r) for r in rows] | |
| 122 | + | |
| 123 | + | |
| 91 | 124 | async def prices_of_provider(conn: AsyncConnection, provider_id: str) -> list[dict[str, Any]]: |
| 92 | 125 | rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.provider_id = :id and p.valid_to is null order by m.canonical_name limit 500", id=provider_id) |
| 93 | 126 | return [price_row(r) for r in rows] |
@@ -98,7 +131,43 @@ async def results_of_model(conn: AsyncConnection, model_id: str) -> list[dict[st | ||
| 98 | 131 | return [result_row(r) for r in rows] |
| 99 | 132 | |
| 100 | 133 | |
| 101 | −async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int = 100, offset: int = 0, config: str | None = None, history: bool = False) -> list[dict[str, Any]]: | |
| 134 | +async def benchmarks_of_model(conn: AsyncConnection, model_id: str) -> dict[str, Any]: | |
| 135 | + """Current results grouped by benchmark → metric → (config group): best row, n_rows, trust level, comparability group label.""" | |
| 136 | + rows = await fetch_all(conn, """ | |
| 137 | + select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.unit, r.higher_is_better, r.config, r.evaluated_at, r.observed_at, r.source_url, r.tier, r.confidence, | |
| 138 | + r.config_key, r.trust_level, r.extractor, s.key as source_key, b.slug as benchmark_slug, b.canonical_name as benchmark_name, b.attributes->>'category' as category | |
| 139 | + from benchmark_results r join entities b on b.id = r.benchmark_id left join sources s on s.id = r.source_id | |
| 140 | + where r.model_id = :id and r.valid_to is null and r.is_current order by b.canonical_name, r.observed_at desc limit 500""", id=model_id) | |
| 141 | + for r in rows: | |
| 142 | + r["model_name"] = None | |
| 143 | + enrich(r) | |
| 144 | + by_bench: dict[str, dict[str, Any]] = {} | |
| 145 | + for r in rows: | |
| 146 | + b = by_bench.setdefault(r["benchmark_id"], {"benchmark": {"id": r["benchmark_id"], "slug": r["benchmark_slug"], "name": r["benchmark_name"], "category": r["category"]}, "metrics": {}}) | |
| 147 | + m = b["metrics"].setdefault(r["metric_canonical"], {"metric": r["metric_canonical"], "groups": {}}) | |
| 148 | + g = m["groups"].setdefault(r["config_key"], {"config_key": r["config_key"], "label": group_label(r["metric_canonical"], r.get("config")), "rows": []}) | |
| 149 | + g["rows"].append(r) | |
| 150 | + items = [] | |
| 151 | + for b in by_bench.values(): | |
| 152 | + metrics = [] | |
| 153 | + for m in b["metrics"].values(): | |
| 154 | + groups = [] | |
| 155 | + for g in m["groups"].values(): | |
| 156 | + hib = all(x.get("higher_is_better", True) for x in g["rows"]) | |
| 157 | + best = max(g["rows"], key=lambda x: x["score"]) if hib else min(g["rows"], key=lambda x: x["score"]) | |
| 158 | + groups.append({"config_key": g["config_key"], "comparability_group": g["label"], "n_rows": len(g["rows"]), "higher_is_better": hib, | |
| 159 | + "best": {"score": best["score"], "unit": best.get("unit"), "trust_level": best["trust_level"], "trust_label": TRUST_LABELS.get(best["trust_level"], best["trust_level"]), | |
| 160 | + "config": config_summary(best.get("config")), "evaluated_at": best.get("evaluated_at"), "observed_at": best["observed_at"], | |
| 161 | + "source_url": best.get("source_url"), "tier": best.get("tier"), "result_id": best["id"]}, | |
| 162 | + "trust_levels": sorted({x["trust_level"] for x in g["rows"]})}) | |
| 163 | + metrics.append({"metric": m["metric"], "groups": groups}) | |
| 164 | + items.append({**b["benchmark"], "metrics": metrics}) | |
| 165 | + return {"items": items, "total_rows": len(rows), "note": "Current rows only, grouped by benchmark → canonical metric → comparability group (task configuration). " | |
| 166 | + "Effort variants folded into this model appear as rows of the same group."} | |
| 167 | + | |
| 168 | + | |
| 169 | +async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int = 100, offset: int = 0, config: str | None = None, history: bool = False, | |
| 170 | + metric: str | None = None, config_key: str | None = None) -> list[dict[str, Any]]: | |
| 102 | 171 | where = ["r.benchmark_id = :id"] |
| 103 | 172 | params: dict[str, Any] = {"id": benchmark_id, "lim": limit, "off": offset} |
| 104 | 173 | if not history: |
@@ -106,8 +175,20 @@ async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int = | ||
| 106 | 175 | if config: |
| 107 | 176 | where.append("r.config::text ilike :cfg") |
| 108 | 177 | params["cfg"] = f"%{config}%" |
| 109 | − rows = await fetch_all(conn, f"select {RESULT_COLS} from {RESULT_FROM} where {' and '.join(where)} order by {RESULT_ORDER} limit :lim offset :off", **params) | |
| 110 | − return [result_row(r) for r in rows] | |
| 178 | + if metric: | |
| 179 | + where.append("lower(r.metric) = lower(:metric)") | |
| 180 | + params["metric"] = metric | |
| 181 | + if config_key: | |
| 182 | + where.append("r.config_key = :ck") | |
| 183 | + params["ck"] = config_key | |
| 184 | + rows = await fetch_all(conn, f"select {RESULT_COLS}, r.config_key, r.trust_level from {RESULT_FROM} where {' and '.join(where)} order by {RESULT_ORDER} limit :lim offset :off", **params) | |
| 185 | + out = [] | |
| 186 | + for r in rows: | |
| 187 | + item = result_row(r) | |
| 188 | + item["config_key"] = r.get("config_key") | |
| 189 | + item["trust_level"] = r.get("trust_level") | |
| 190 | + out.append(item) | |
| 191 | + return out | |
| 111 | 192 | |
| 112 | 193 | |
| 113 | 194 | async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dict[str, Any]]]: |
@@ -126,7 +207,7 @@ async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dic | ||
| 126 | 207 | union |
| 127 | 208 | select r.subject_id, down.depth + 1 from down join relations r on r.object_id = down.id and r.valid_to is null and r.predicate = any(cast(:preds as text[])) where down.depth < 3) |
| 128 | 209 | select distinct on (e.id) down.depth, {ENTITY_COLS} from down join entities e on e.id = down.id left join entities eo on eo.id = e.organization_id |
| 129 | − where e.id <> :id and e.merged_into is null order by e.id, down.depth""", id=model_id, preds=desc_preds) | |
| 210 | + where e.id <> :id and e.merged_into is null and e.entity_type <> 'artifact' order by e.id, down.depth""", id=model_id, preds=desc_preds) | |
| 130 | 211 | quants = await fetch_all(conn, f"""select {ENTITY_COLS} from relations r join entities e on e.id = r.subject_id left join entities eo on eo.id = e.organization_id |
| 131 | 212 | where r.object_id = :id and r.predicate = 'quantized_from' and r.valid_to is null and e.merged_into is null |
| 132 | 213 | order by e.canonical_name limit 100""", id=model_id) |
@@ -135,6 +216,111 @@ async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dic | ||
| 135 | 216 | "quantizations": [entity_summary(r) for r in quants]} |
| 136 | 217 | |
| 137 | 218 | |
| 219 | +async def artifacts_of_model(conn: AsyncConnection, model_id: str) -> dict[str, Any]: | |
| 220 | + """Artifacts (entity_type 'artifact' with canonical_id = model, or `artifact_of` relation) grouped by kind.""" | |
| 221 | + rows = await fetch_all(conn, f""" | |
| 222 | + with ids as (select e.id, e.artifact_kind from entities e where e.canonical_id = :id and e.entity_type = 'artifact' and e.merged_into is null | |
| 223 | + union select r.subject_id, null from relations r join entities x on x.id = r.subject_id where r.object_id = :id and r.predicate in ('artifact_of','quantized_from') | |
| 224 | + and r.valid_to is null and x.entity_type = 'artifact' and x.merged_into is null) | |
| 225 | + select distinct on (e.id) coalesce(e.artifact_kind, ids.artifact_kind) as kind, {ENTITY_COLS} from ids join entities e on e.id = ids.id left join entities eo on eo.id = e.organization_id | |
| 226 | + order by e.id limit 300""", id=model_id) | |
| 227 | + groups: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 228 | + for r in rows: | |
| 229 | + kind = r["kind"] if r["kind"] in ARTIFACT_KINDS else "other" | |
| 230 | + s = entity_summary(r) or {} | |
| 231 | + s["artifact_kind"] = r["kind"] | |
| 232 | + groups[kind].append(s) | |
| 233 | + ordered = [k for k in (*ARTIFACT_KINDS, "other") if k in groups] | |
| 234 | + return {"items": [{"kind": k, "items": sorted(groups[k], key=lambda x: x["name"] or ""), "count": len(groups[k])} for k in ordered], "total": len(rows)} | |
| 235 | + | |
| 236 | + | |
| 237 | +async def family_of_model(conn: AsyncConnection, row: dict[str, Any]) -> dict[str, Any] | None: | |
| 238 | + fid = row.get("family_id") | |
| 239 | + if fid: | |
| 240 | + fam = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.id = :id", id=fid) | |
| 241 | + if fam: | |
| 242 | + return entity_summary(fam) | |
| 243 | + label = (row.get("attributes") or {}).get("family") | |
| 244 | + if label: | |
| 245 | + fam = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model_family' and e.merged_into is null and e.canonical_name ilike :n limit 1", n=label) | |
| 246 | + return entity_summary(fam) if fam else {"id": None, "entity_type": "model_family", "slug": None, "name": label, "canonical": False, | |
| 247 | + "note": "family label from attributes; no model_family entity yet"} | |
| 248 | + return None | |
| 249 | + | |
| 250 | + | |
| 251 | +async def canonical_of_artifact(conn: AsyncConnection, row: dict[str, Any]) -> dict[str, Any] | None: | |
| 252 | + cid = row.get("canonical_id") | |
| 253 | + if not cid: | |
| 254 | + rel = await fetch_one(conn, "select object_id from relations where subject_id = :id and predicate in ('artifact_of','quantized_from') and valid_to is null order by predicate limit 1", id=row["id"]) | |
| 255 | + cid = rel["object_id"] if rel else None | |
| 256 | + if not cid: | |
| 257 | + return None | |
| 258 | + can = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.id = :id", id=cid) | |
| 259 | + return entity_summary(can) if can else None | |
| 260 | + | |
| 261 | + | |
| 262 | +async def identity_of_model(conn: AsyncConnection, model_id: str, identifiers: list[dict[str, str]]) -> dict[str, Any]: | |
| 263 | + counts = await fetch_one(conn, """ | |
| 264 | + select (select count(*) from entities a where a.canonical_id = :id and a.entity_type = 'artifact' and a.merged_into is null and coalesce(a.artifact_kind, '') <> 'checkpoint') as third_party, | |
| 265 | + (select count(*) from entities a where a.canonical_id = :id and a.entity_type = 'artifact' and a.merged_into is null and a.artifact_kind = 'checkpoint') as official_artifacts, | |
| 266 | + (select count(distinct p.provider_id) from prices p where p.model_id = :id and p.valid_to is null) as providers, | |
| 267 | + (select count(*) from entities v where v.merged_into = :id) as folded_variants""", id=model_id) | |
| 268 | + hf_repos = [i["value"] for i in identifiers if i["scheme"] == "hf_repo"] | |
| 269 | + api_ids = sorted({i["value"] for i in identifiers if i["scheme"].endswith("_model_id") or i["scheme"] in ("openrouter", "api_model_id", "artificial_analysis")}) | |
| 270 | + c = counts or {} | |
| 271 | + return {"canonical_model": True, "official_checkpoints": hf_repos, "official_artifacts": int(c.get("official_artifacts") or 0), "third_party_artifacts": int(c.get("third_party") or 0), | |
| 272 | + "provider_deployments": int(c.get("providers") or 0), "folded_variants": int(c.get("folded_variants") or 0), "api_aliases": api_ids, | |
| 273 | + "note": "official_checkpoints = hf_repo identifiers carried by the model itself; artifacts are separate entities pointing here through canonical_id."} | |
| 274 | + | |
| 275 | + | |
| 276 | +def licence_block(attrs: dict[str, Any]) -> dict[str, Any] | None: | |
| 277 | + key = attrs.get("license_key") or normalize_license(attrs.get("license")) | |
| 278 | + info = LICENSES.get(key) if key else None | |
| 279 | + if not info: | |
| 280 | + return {"key": None, "raw": attrs.get("license") or attrs.get("license_raw"), "note": "licence label not classified in the ontology"} if attrs.get("license") else None | |
| 281 | + return {**info.as_dict(), "raw": attrs.get("license_raw") or attrs.get("license"), "url_observed": attrs.get("license_url")} | |
| 282 | + | |
| 283 | + | |
| 284 | +def openness_block(attrs: dict[str, Any]) -> dict[str, Any] | None: | |
| 285 | + raw = attrs.get("openness") | |
| 286 | + cat = normalize_openness(raw) if raw else None | |
| 287 | + dims = attrs.get("openness_dimensions") | |
| 288 | + if not isinstance(dims, dict): | |
| 289 | + key = attrs.get("license_key") or normalize_license(attrs.get("license")) | |
| 290 | + weights = True if cat in ("open-weights", "open-source", "restricted-weights") else False if cat == "proprietary" else None | |
| 291 | + dims = openness_dimensions(weights_available=weights, license_key=key, license_raw=attrs.get("license")) | |
| 292 | + if not cat and not raw: | |
| 293 | + return None | |
| 294 | + return {"category": cat or "unknown", "raw": raw, "label": OPENNESS_LABELS.get(cat or "unknown"), "definition": OPENNESS_DEFINITIONS.get(cat or "unknown"), "dimensions": dims, | |
| 295 | + "note": "dimensions marked null are unknown, not false"} | |
| 296 | + | |
| 297 | + | |
| 298 | +async def version_history_of(conn: AsyncConnection, entity_id: str) -> list[dict[str, Any]]: | |
| 299 | + rows = await fetch_all(conn, f"select {CLAIM_COLS} from {CLAIM_FROM} where c.entity_id = :id and c.property = any(cast(:props as text[])) and c.status <> 'retracted' " | |
| 300 | + f"order by c.property, c.valid_from asc, c.observed_at asc limit 2000", id=entity_id, props=list(VERSIONED_PROPERTIES)) | |
| 301 | + by_prop: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 302 | + for r in rows: | |
| 303 | + by_prop[r["property"]].append(r) | |
| 304 | + out = [] | |
| 305 | + for prop in VERSIONED_PROPERTIES: | |
| 306 | + claims = by_prop.get(prop) | |
| 307 | + if not claims: | |
| 308 | + continue | |
| 309 | + transitions = [] | |
| 310 | + prev: Any = None | |
| 311 | + for c in claims: | |
| 312 | + if c["status"] == "conflicting": | |
| 313 | + continue | |
| 314 | + if c["value"] == prev and transitions: | |
| 315 | + transitions[-1]["valid_to"] = c["valid_to"] or transitions[-1]["valid_to"] | |
| 316 | + continue | |
| 317 | + transitions.append({"from": prev, "to": c["value"], "valid_from": c["valid_from"], "valid_to": c["valid_to"], "effective_at": c["effective_at"], | |
| 318 | + "source_url": c["source_url"], "tier": c["tier"], "claim_id": c["id"], "status": c["status"]}) | |
| 319 | + prev = c["value"] | |
| 320 | + out.append({"property": prop, "transitions": transitions, "current": transitions[-1]["to"] if transitions else None}) | |
| 321 | + return out | |
| 322 | + | |
| 323 | + | |
| 138 | 324 | async def providers_of_model(conn: AsyncConnection, model_id: str) -> list[dict[str, Any]]: |
| 139 | 325 | rows = await fetch_all(conn, f""" |
| 140 | 326 | with ids as (select r.object_id as id from relations r where r.subject_id = :id and r.predicate = 'available_through' and r.valid_to is null |
@@ -173,15 +359,20 @@ async def related_by_type(conn: AsyncConnection, entity_id: str, etype: str, *, | ||
| 173 | 359 | return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0 |
| 174 | 360 | |
| 175 | 361 | |
| 176 | −async def entity_detail(row: dict[str, Any]) -> dict[str, Any]: | |
| 177 | − """Full detail. Blocks run on separate pooled connections concurrently (read-only, so no transaction needed).""" | |
| 178 | − eid, etype = row["id"], row["entity_type"] | |
| 362 | +async def _run_group(tasks: list[tuple[str, Any, tuple[Any, ...], dict[str, Any]]]) -> dict[str, Any]: | |
| 363 | + """Run the blocks of one group sequentially on a single pooled connection.""" | |
| 364 | + out: dict[str, Any] = {} | |
| 365 | + async with connection() as conn: | |
| 366 | + for name, fn, args, kw in tasks: | |
| 367 | + out[name] = await fn(conn, *args, **kw) | |
| 368 | + return out | |
| 179 | 369 | |
| 180 | − async def one(fn: Any, *args: Any, **kw: Any) -> Any: | |
| 181 | − async with connection() as conn: | |
| 182 | − return await fn(conn, *args, **kw) | |
| 183 | 370 | |
| 371 | +async def entity_detail(row: dict[str, Any]) -> dict[str, Any]: | |
| 372 | + """Full detail. Blocks are spread over ≤ 4 concurrent groups (one connection each).""" | |
| 373 | + eid, etype = row["id"], row["entity_type"] | |
| 184 | 374 | provenance = dict(row.get("provenance") or {}) |
| 375 | + attrs = row.get("attributes") or {} | |
| 185 | 376 | |
| 186 | 377 | async def base(conn: AsyncConnection) -> dict[str, Any]: |
| 187 | 378 | aliases = await fetch_all(conn, "select alias from entity_aliases where entity_id = :id order by kind, alias limit 200", id=eid) |
@@ -189,36 +380,42 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]: | ||
| 189 | 380 | await enrich_provenance(conn, provenance) |
| 190 | 381 | return {"aliases": [a["alias"] for a in aliases], "identifiers": [{"scheme": i["scheme"], "value": i["value"]} for i in idents]} |
| 191 | 382 | |
| 192 | − tasks: dict[str, Any] = {"base": one(base), "relations": one(relations_grouped, eid), "sources": one(sources_of, eid), "timeline": one(timeline_of, eid, etype)} | |
| 383 | + T = lambda name, fn, *args, **kw: (name, fn, args, kw) | |
| 384 | + groups: list[list[tuple[str, Any, tuple[Any, ...], dict[str, Any]]]] = [ | |
| 385 | + [T("base", base), T("relations", relations_grouped, eid)], | |
| 386 | + [T("sources", sources_of, eid), T("timeline", timeline_of, eid, etype)], | |
| 387 | + ] | |
| 193 | 388 | if etype == "model": |
| 194 | − tasks["prices"] = one(prices_of_model, eid, current_only=True) | |
| 195 | − tasks["price_history"] = one(prices_of_model, eid, current_only=False) | |
| 196 | − tasks["results"] = one(results_of_model, eid) | |
| 197 | − tasks["lineage"] = one(lineage_of, eid) | |
| 198 | − tasks["providers"] = one(providers_of_model, eid) | |
| 199 | − tasks["hardware_fit"] = one(hardware_fit_of_model, row["attributes"] or {}) | |
| 200 | − tasks["papers"] = one(related_by_type, eid, "paper", limit=24) | |
| 201 | − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24) | |
| 389 | + groups[0] += [T("prices", prices_of_model, eid, current_only=True), T("deployments", deployments_of_model, eid), T("providers", providers_of_model, eid)] | |
| 390 | + groups[1] += [T("price_history", prices_of_model, eid, current_only=False), T("family", family_of_model, row), T("artifacts", artifacts_of_model, eid)] | |
| 391 | + groups.append([T("results", results_of_model, eid), T("benchmarks", benchmarks_of_model, eid), T("version_history", version_history_of, eid)]) | |
| 392 | + groups.append([T("lineage", lineage_of, eid), T("hardware_fit", hardware_fit_of_model, attrs), T("papers", related_by_type, eid, "paper", limit=24), | |
| 393 | + T("repositories", related_by_type, eid, "repository", limit=24)]) | |
| 394 | + elif etype == "artifact": | |
| 395 | + groups[0] += [T("canonical", canonical_of_artifact, row), T("prices", prices_of_model, eid, current_only=True)] | |
| 396 | + groups[1] += [T("lineage", lineage_of, eid), T("hardware_fit", hardware_fit_of_model, attrs)] | |
| 202 | 397 | elif etype in COMPANY_TYPES: |
| 203 | − tasks["models"] = one(related_by_type, eid, "model", limit=50, include_org_children=True) | |
| 204 | − tasks["papers"] = one(related_by_type, eid, "paper", limit=24, include_org_children=True) | |
| 205 | − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24, include_org_children=True) | |
| 398 | + groups[0] += [T("models", related_by_type, eid, "model", limit=50, include_org_children=True)] | |
| 399 | + groups[1] += [T("papers", related_by_type, eid, "paper", limit=24, include_org_children=True), T("repositories", related_by_type, eid, "repository", limit=24, include_org_children=True)] | |
| 206 | 400 | elif etype == "provider": |
| 207 | − tasks["prices"] = one(prices_of_provider, eid) | |
| 208 | − tasks["models"] = one(_provider_models, eid) | |
| 401 | + groups[0] += [T("prices", prices_of_provider, eid)] | |
| 402 | + groups[1] += [T("models", _provider_models, eid)] | |
| 209 | 403 | elif etype == "benchmark": |
| 210 | − tasks["results"] = one(leaderboard, eid, limit=100) | |
| 404 | + groups[0] += [T("results", leaderboard, eid, limit=100)] | |
| 211 | 405 | elif etype == "hardware": |
| 212 | − tasks["models"] = one(related_by_type, eid, "model", limit=50) | |
| 213 | − elif etype == "framework": | |
| 214 | − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24) | |
| 406 | + groups[0] += [T("models", related_by_type, eid, "model", limit=50)] | |
| 407 | + elif etype in ("framework", "library", "runtime"): | |
| 408 | + groups[0] += [T("repositories", related_by_type, eid, "repository", limit=24)] | |
| 409 | + elif etype == "model_family": | |
| 410 | + groups[0] += [T("models", _family_models, eid)] | |
| 215 | 411 | |
| 216 | − keys = list(tasks) | |
| 217 | − values = await asyncio.gather(*(tasks[k] for k in keys)) | |
| 218 | − blocks = dict(zip(keys, values, strict=True)) | |
| 412 | + results = await asyncio.gather(*(_run_group(g) for g in groups[:MAX_CONCURRENT_GROUPS])) | |
| 413 | + blocks: dict[str, Any] = {} | |
| 414 | + for r in results: | |
| 415 | + blocks.update(r) | |
| 219 | 416 | |
| 220 | 417 | detail = entity_summary(row) or {} |
| 221 | − detail["attributes"] = row.get("attributes") or {} | |
| 418 | + detail["attributes"] = attrs | |
| 222 | 419 | detail["provenance"] = provenance |
| 223 | 420 | detail.update(blocks.pop("base")) |
| 224 | 421 | detail["relations"] = blocks.pop("relations") |
@@ -232,11 +429,33 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]: | ||
| 232 | 429 | detail[k] = v[0] |
| 233 | 430 | elif v is not None: |
| 234 | 431 | detail[k] = v |
| 235 | − if etype == "model" and "hardware_fit" in detail: | |
| 236 | − detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS | |
| 432 | + if etype == "model": | |
| 433 | + if "hardware_fit" in detail: | |
| 434 | + detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS | |
| 435 | + detail["identity"] = await _with_conn(identity_of_model, eid, detail.get("identifiers") or []) | |
| 436 | + lic = licence_block(attrs) | |
| 437 | + if lic is not None: | |
| 438 | + detail["licence"] = lic | |
| 439 | + opn = openness_block(attrs) | |
| 440 | + if opn is not None: | |
| 441 | + detail["openness"] = opn | |
| 442 | + detail["family_id"] = row.get("family_id") | |
| 443 | + detail["identity_confidence"] = row.get("identity_confidence") | |
| 444 | + if etype == "artifact": | |
| 445 | + detail["artifact_kind"] = row.get("artifact_kind") | |
| 446 | + detail.setdefault("canonical", None) | |
| 447 | + if "hardware_fit" in detail: | |
| 448 | + detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS | |
| 449 | + if row.get("redirected_from"): | |
| 450 | + detail["redirected_from"] = row["redirected_from"] | |
| 237 | 451 | return detail |
| 238 | 452 | |
| 239 | 453 | |
| 454 | +async def _with_conn(fn: Any, *args: Any) -> Any: | |
| 455 | + async with connection() as conn: | |
| 456 | + return await fn(conn, *args) | |
| 457 | + | |
| 458 | + | |
| 240 | 459 | async def _provider_models(conn: AsyncConnection, provider_id: str) -> tuple[list[dict[str, Any]], int]: |
| 241 | 460 | rows = await fetch_all(conn, f""" |
| 242 | 461 | with ids as (select p.model_id as id from prices p where p.provider_id = :id and p.valid_to is null |
@@ -246,11 +465,25 @@ async def _provider_models(conn: AsyncConnection, provider_id: str) -> tuple[lis | ||
| 246 | 465 | return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0 |
| 247 | 466 | |
| 248 | 467 | |
| 468 | +async def _family_models(conn: AsyncConnection, family_id: str) -> tuple[list[dict[str, Any]], int]: | |
| 469 | + rows = await fetch_all(conn, f"""select count(*) over () as total, {ENTITY_COLS} from {ENTITY_FROM} where e.family_id = :id and e.entity_type = 'model' and e.merged_into is null | |
| 470 | + order by e.attributes->>'release_date' desc nulls last, e.canonical_name limit 50""", id=family_id) | |
| 471 | + return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0 | |
| 472 | + | |
| 473 | + | |
| 249 | 474 | __all__ = [ |
| 475 | + "VERSIONED_PROPERTIES", | |
| 476 | + "artifacts_of_model", | |
| 477 | + "benchmarks_of_model", | |
| 478 | + "deployments_of_model", | |
| 250 | 479 | "entity_detail", |
| 480 | + "event_date_col", | |
| 481 | + "family_of_model", | |
| 251 | 482 | "hardware_fit_of_model", |
| 252 | 483 | "leaderboard", |
| 484 | + "licence_block", | |
| 253 | 485 | "lineage_of", |
| 486 | + "openness_block", | |
| 254 | 487 | "prices_of_model", |
| 255 | 488 | "prices_of_provider", |
| 256 | 489 | "providers_of_model", |
@@ -259,4 +492,5 @@ __all__ = [ | ||
| 259 | 492 | "results_of_model", |
| 260 | 493 | "sources_of", |
| 261 | 494 | "timeline_of", |
| 495 | + "version_history_of", | |
| 262 | 496 | ] |
added
src/aiatlas/api/etag.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +"""Weak ETag + Cache-Control on public GET responses (API 1.1). | |
| 2 | + | |
| 3 | +Pure ASGI middleware so it can sit *inside* GZip (the hash is computed on the uncompressed JSON body — gzip output embeds a | |
| 4 | +timestamp and would never be stable). `If-None-Match` matching the body hash → `304 Not Modified` with an empty body. | |
| 5 | +Admin, docs and non-200 responses are left untouched.""" | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +import hashlib | |
| 9 | +from typing import Any | |
| 10 | + | |
| 11 | +from starlette.types import ASGIApp, Message, Receive, Scope, Send | |
| 12 | + | |
| 13 | +PUBLIC_PREFIX = "/api/v1/" | |
| 14 | +SKIP_PREFIXES = ("/api/v1/admin", "/api/v1/docs", "/api/v1/openapi.json", "/api/v1/api-keys") | |
| 15 | +CACHE_CONTROL = "public, max-age=60, stale-while-revalidate=300" | |
| 16 | +MAX_BUFFER = 8 * 1024 * 1024 # bodies above this are passed through unhashed | |
| 17 | + | |
| 18 | + | |
| 19 | +def weak_etag(body: bytes) -> str: | |
| 20 | + return 'W/"' + hashlib.sha1(body).hexdigest() + '"' | |
| 21 | + | |
| 22 | + | |
| 23 | +def _etags_match(header: str | None, etag: str) -> bool: | |
| 24 | + if not header: | |
| 25 | + return False | |
| 26 | + if header.strip() == "*": | |
| 27 | + return True | |
| 28 | + wanted = {t.strip() for t in header.split(",")} | |
| 29 | + strong = etag.removeprefix("W/") | |
| 30 | + return etag in wanted or strong in wanted or any((t.removeprefix("W/")) == strong for t in wanted) | |
| 31 | + | |
| 32 | + | |
| 33 | +class ETagMiddleware: | |
| 34 | + def __init__(self, app: ASGIApp) -> None: | |
| 35 | + self.app = app | |
| 36 | + | |
| 37 | + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: | |
| 38 | + if scope["type"] != "http" or scope.get("method") != "GET": | |
| 39 | + await self.app(scope, receive, send) | |
| 40 | + return | |
| 41 | + path: str = scope.get("path", "") | |
| 42 | + if not path.startswith(PUBLIC_PREFIX) or path.startswith(SKIP_PREFIXES): | |
| 43 | + await self.app(scope, receive, send) | |
| 44 | + return | |
| 45 | + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} | |
| 46 | + inm = headers.get("if-none-match") | |
| 47 | + | |
| 48 | + start: Message | None = None | |
| 49 | + chunks: list[bytes] = [] | |
| 50 | + passthrough = False | |
| 51 | + size = 0 | |
| 52 | + | |
| 53 | + async def send_wrapper(message: Message) -> None: | |
| 54 | + nonlocal start, passthrough, size | |
| 55 | + if passthrough: | |
| 56 | + await send(message) | |
| 57 | + return | |
| 58 | + if message["type"] == "http.response.start": | |
| 59 | + start = message | |
| 60 | + if message.get("status") != 200: | |
| 61 | + passthrough = True | |
| 62 | + await send(message) | |
| 63 | + return | |
| 64 | + if message["type"] == "http.response.body": | |
| 65 | + body = message.get("body", b"") | |
| 66 | + size += len(body) | |
| 67 | + if size > MAX_BUFFER: | |
| 68 | + passthrough = True | |
| 69 | + assert start is not None | |
| 70 | + await send(start) | |
| 71 | + for c in chunks: | |
| 72 | + await send({"type": "http.response.body", "body": c, "more_body": True}) | |
| 73 | + await send(message) | |
| 74 | + return | |
| 75 | + chunks.append(body) | |
| 76 | + if message.get("more_body"): | |
| 77 | + return | |
| 78 | + assert start is not None | |
| 79 | + full = b"".join(chunks) | |
| 80 | + etag = weak_etag(full) | |
| 81 | + raw_headers: list[tuple[bytes, bytes]] = [(k, v) for k, v in start.get("headers", []) if k.lower() not in (b"etag", b"cache-control")] | |
| 82 | + raw_headers.append((b"etag", etag.encode("latin-1"))) | |
| 83 | + raw_headers.append((b"cache-control", CACHE_CONTROL.encode("latin-1"))) | |
| 84 | + if _etags_match(inm, etag): | |
| 85 | + keep = {b"etag", b"cache-control", b"vary", b"x-content-type-options", b"referrer-policy", b"access-control-allow-origin"} | |
| 86 | + hdrs = [(k, v) for k, v in raw_headers if k.lower() in keep] | |
| 87 | + await send({"type": "http.response.start", "status": 304, "headers": hdrs}) | |
| 88 | + await send({"type": "http.response.body", "body": b"", "more_body": False}) | |
| 89 | + return | |
| 90 | + await send({"type": "http.response.start", "status": 200, "headers": raw_headers}) | |
| 91 | + await send({"type": "http.response.body", "body": full, "more_body": False}) | |
| 92 | + return | |
| 93 | + await send(message) | |
| 94 | + | |
| 95 | + await self.app(scope, receive, send_wrapper) | |
| 96 | + | |
| 97 | + | |
| 98 | +def etag_of(value: Any) -> str: | |
| 99 | + """Helper for tests and callers holding an already-rendered body.""" | |
| 100 | + return weak_etag(value if isinstance(value, bytes) else str(value).encode()) | |
| 101 | + | |
| 102 | + | |
| 103 | +__all__ = ["CACHE_CONTROL", "ETagMiddleware", "etag_of", "weak_etag"] | |
modified
src/aiatlas/api/main.py
+20 −7
@@ -1,4 +1,4 @@ | ||
| 1 | −"""FastAPI application — `/api/v1`. Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`.""" | |
| 1 | +"""FastAPI application — `/api/v1` (API 1.1). Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`.""" | |
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | |
| 4 | 4 | import asyncio |
@@ -15,16 +15,23 @@ from starlette.exceptions import HTTPException as StarletteHTTPException | ||
| 15 | 15 | |
| 16 | 16 | import aiatlas |
| 17 | 17 | from aiatlas.api.common import AtlasJSONResponse |
| 18 | +from aiatlas.api.etag import ETagMiddleware | |
| 18 | 19 | from aiatlas.api.routers import ( |
| 19 | 20 | admin, |
| 21 | + admin_quality, | |
| 20 | 22 | benchmarks, |
| 21 | 23 | changes, |
| 24 | + claims, | |
| 22 | 25 | companies, |
| 23 | 26 | compare, |
| 27 | + deployments, | |
| 24 | 28 | diff, |
| 25 | 29 | entities, |
| 26 | 30 | explore, |
| 31 | + families, | |
| 32 | + graph, | |
| 27 | 33 | hardware, |
| 34 | + intelligence, | |
| 28 | 35 | misc, |
| 29 | 36 | models, |
| 30 | 37 | papers, |
@@ -42,13 +49,14 @@ from aiatlas.services import cache | ||
| 42 | 49 | from aiatlas.services.llm import gateway |
| 43 | 50 | |
| 44 | 51 | log = logging.getLogger("aiatlas.api") |
| 52 | +API_VERSION = "1.1" | |
| 45 | 53 | |
| 46 | 54 | |
| 47 | 55 | @asynccontextmanager |
| 48 | 56 | async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] |
| 49 | 57 | setup_logging(service="aia-api") |
| 50 | 58 | settings.ensure_dirs() |
| 51 | − log.info("api started", extra={"version": aiatlas.__version__, "env": settings.app_env, "port": settings.api_port}) | |
| 59 | + log.info("api started", extra={"version": aiatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port}) | |
| 52 | 60 | yield |
| 53 | 61 | await cache.close() |
| 54 | 62 | await dispose() |
@@ -57,12 +65,14 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] | ||
| 57 | 65 | app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse, |
| 58 | 66 | docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json", |
| 59 | 67 | description="The global intelligence layer for artificial intelligence: models, companies, papers, providers, prices, benchmarks, hardware — " |
| 60 | − "with provenance and history on every fact. Contract: docs/API.md.") | |
| 68 | + "with provenance and history on every fact. Contract: docs/API.md (API 1.1, additive over v1).") | |
| 61 | 69 | |
| 70 | +# Middleware order: the LAST added is the OUTERMOST. ETag must be inside GZip (hash of the uncompressed body), so it is added first. | |
| 71 | +app.add_middleware(ETagMiddleware) | |
| 62 | 72 | app.add_middleware(GZipMiddleware, minimum_size=1024) |
| 63 | 73 | _origins = {settings.site_url, "https://www.ai-atlas.co", "https://ai-atlas.co", "http://localhost:8320", "http://127.0.0.1:8320", "http://localhost:8330", "http://127.0.0.1:8330"} |
| 64 | 74 | app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "OPTIONS"], |
| 65 | − allow_headers=["*"], max_age=600) | |
| 75 | + allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version"], max_age=600) | |
| 66 | 76 | |
| 67 | 77 | |
| 68 | 78 | @app.middleware("http") |
@@ -74,6 +84,7 @@ async def security_headers(request: Request, call_next): # type: ignore[no-unty | ||
| 74 | 84 | return AtlasJSONResponse({"detail": "internal server error"}, status_code=500) |
| 75 | 85 | response.headers["x-content-type-options"] = "nosniff" |
| 76 | 86 | response.headers["referrer-policy"] = "strict-origin-when-cross-origin" |
| 87 | + response.headers["x-api-version"] = API_VERSION | |
| 77 | 88 | return response |
| 78 | 89 | |
| 79 | 90 | |
@@ -117,7 +128,7 @@ async def _health() -> dict[str, Any]: | ||
| 117 | 128 | cached = {"reachable": False} |
| 118 | 129 | await cache.cache_set("health:llm", cached, 300) |
| 119 | 130 | llm.update(cached) |
| 120 | − return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)} | |
| 131 | + return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "api_version": API_VERSION, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)} | |
| 121 | 132 | |
| 122 | 133 | |
| 123 | 134 | @app.get("/health", tags=["health"]) |
@@ -131,7 +142,9 @@ async def health_v1() -> dict[str, Any]: | ||
| 131 | 142 | |
| 132 | 143 | |
| 133 | 144 | # Order matters only where a literal path and a `{param}` path share a prefix — literal routes live in the same router and are declared first. |
| 134 | −for r in (stats, search, models, companies, papers, providers, prices, benchmarks, hardware, explore, changes, timeline, compare, diff, sources, misc, entities, admin): | |
| 145 | +# `intelligence`, `families`, `graph`, `claims`, `deployments` and `misc` mount literal paths under /api/v1 and must precede `entities` (/{slug_or_id}). | |
| 146 | +for r in (stats, search, models, companies, papers, providers, prices, deployments, benchmarks, hardware, explore, changes, timeline, compare, diff, sources, | |
| 147 | + intelligence, families, graph, claims, misc, entities, admin, admin_quality): | |
| 135 | 148 | app.include_router(r.router) |
| 136 | 149 | |
| 137 | −__all__ = ["app"] | |
| 150 | +__all__ = ["API_VERSION", "app"] | |
modified
src/aiatlas/api/routers/admin.py
+41 −6
@@ -8,10 +8,10 @@ import time | ||
| 8 | 8 | from datetime import UTC, datetime |
| 9 | 9 | from typing import Any |
| 10 | 10 | |
| 11 | −from fastapi import APIRouter, Depends, Query | |
| 11 | +from fastapi import APIRouter, Depends, Query, Request | |
| 12 | 12 | from pydantic import BaseModel, Field |
| 13 | 13 | |
| 14 | −from aiatlas.api.common import PAGINATION, ApiError, Pagination, page, rate_limit, require_admin | |
| 14 | +from aiatlas.api.common import ADMIN_DEPENDENCIES, PAGINATION, ApiError, Pagination, audit, client_ip, page | |
| 15 | 15 | from aiatlas.config import settings |
| 16 | 16 | from aiatlas.connectors import registry as connector_registry |
| 17 | 17 | from aiatlas.db import connection, execute, fetch_all, fetch_one, fetch_val, jsonb, transaction |
@@ -23,7 +23,32 @@ from aiatlas.services.llm import gateway | ||
| 23 | 23 | from aiatlas.services.merge import merge_entities |
| 24 | 24 | from aiatlas.services.stats import live_counts |
| 25 | 25 | |
| 26 | −router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin), Depends(rate_limit("admin"))]) | |
| 26 | +SKIP_AUDIT_GET = ("/api/v1/admin/overview", "/api/v1/admin/infrastructure", "/api/v1/admin/llm/health", "/api/v1/admin/audit") | |
| 27 | + | |
| 28 | + | |
| 29 | +async def audit_request(request: Request) -> None: | |
| 30 | + """Every admin call leaves a row in `admin_audit_log` (except the dashboard polling GETs). Runs AFTER the rate limit and the token check.""" | |
| 31 | + if request.method == "GET" and request.url.path in SKIP_AUDIT_GET: | |
| 32 | + return | |
| 33 | + payload: dict[str, Any] = {"method": request.method, "path": request.url.path} | |
| 34 | + if request.query_params: | |
| 35 | + payload["query"] = dict(request.query_params) | |
| 36 | + if request.method in ("POST", "PATCH", "PUT", "DELETE"): | |
| 37 | + try: | |
| 38 | + body = await request.body() | |
| 39 | + if body: | |
| 40 | + import orjson | |
| 41 | + | |
| 42 | + payload["body"] = orjson.loads(body) if len(body) < 64 * 1024 else {"truncated": True, "bytes": len(body)} | |
| 43 | + except Exception: # noqa: BLE001 | |
| 44 | + payload["body"] = {"unparsed": True} | |
| 45 | + target = request.path_params.get("name") or request.path_params.get("review_id") or request.path_params.get("job_id") or request.path_params.get("snap_id") \ | |
| 46 | + or request.path_params.get("doc_id") or request.path_params.get("entity_id") or request.path_params.get("run_id") or request.path_params.get("anomaly_id") \ | |
| 47 | + or request.path_params.get("quarantine_id") or request.path_params.get("a") | |
| 48 | + await audit(f"{request.method} {request.url.path}", str(target) if target else None, payload, client_ip(request)) | |
| 49 | + | |
| 50 | + | |
| 51 | +router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[*ADMIN_DEPENDENCIES, Depends(audit_request)]) | |
| 27 | 52 | _STARTED = time.time() |
| 28 | 53 | SNAPSHOT_TEXT_LIMIT = 20 * 1024 |
| 29 | 54 | HIDDEN = ("raw_path", "text_path") |
@@ -422,7 +447,8 @@ async def _accept_claim(conn: Any, claim_id: str) -> dict[str, Any]: | ||
| 422 | 447 | async def duplicates(type: str | None = Query(None, alias="type"), limit: int = Query(100, ge=1, le=500), threshold: float = Query(0.8, ge=0.3, le=1.0)) -> dict[str, Any]: |
| 423 | 448 | where = "a.entity_type = :t" if type else "true" |
| 424 | 449 | async with connection() as conn: |
| 425 | − await execute(conn, "select set_limit(:th)", th=threshold) | |
| 450 | + # `%` uses the pg_trgm GIN index at the session default threshold (0.3, a superset of any threshold ≥ 0.3); the exact bound is applied | |
| 451 | + # with similarity() > :th — no set_limit(), which used to leak a GUC change into the pooled connection. | |
| 426 | 452 | rows = await fetch_all(conn, f""" |
| 427 | 453 | select a.id as a_id, a.slug as a_slug, a.canonical_name as a_name, a.entity_type, a.first_seen_at as a_first_seen_at, oa.canonical_name as a_org, |
| 428 | 454 | b.id as b_id, b.slug as b_slug, b.canonical_name as b_name, b.first_seen_at as b_first_seen_at, ob.canonical_name as b_org, |
@@ -518,9 +544,18 @@ async def infrastructure() -> dict[str, Any]: | ||
| 518 | 544 | "llm": {"available": gateway.available, "engine": gateway.engine.name}, "scheduler_tick_s": settings.scheduler_tick_s} |
| 519 | 545 | |
| 520 | 546 | |
| 547 | +CACHE_PREFIXES_AFTER_RUN = ("/api/v1/stats", "/api/v1/changes", "/api/v1/benchmarks", "/api/v1/prices", "/api/v1/models", "/api/v1/deployments", "/api/v1/frontier", | |
| 548 | + "/api/v1/pulse", "/api/v1/open", "/api/v1/families", "/api/v1/timeline", "/api/v1/diff", "facets:", "frontier:") | |
| 549 | + | |
| 550 | + | |
| 521 | 551 | @router.post("/cache/flush") |
| 522 | −async def cache_flush(prefix: str = "") -> dict[str, Any]: | |
| 523 | − return {"flushed": await cache.cache_invalidate(prefix)} | |
| 552 | +async def cache_flush(prefix: str = "", after_run: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 553 | + """Flush `aia:api:<prefix>*`. `after_run=1` flushes the read paths a connector run invalidates (the scheduler calls `services.cache.cache_invalidate` | |
| 554 | + with these prefixes after every run — see docs/API.md).""" | |
| 555 | + if after_run: | |
| 556 | + flushed = {p: await cache.cache_invalidate(p) for p in CACHE_PREFIXES_AFTER_RUN} | |
| 557 | + return {"flushed": sum(flushed.values()), "by_prefix": flushed} | |
| 558 | + return {"flushed": await cache.cache_invalidate(prefix), "prefix": prefix or "*"} | |
| 524 | 559 | |
| 525 | 560 | |
| 526 | 561 | @router.post("/stats/recompute") |
added
src/aiatlas/api/routers/admin_quality.py
+474 −0
@@ -0,0 +1,474 @@ | ||
| 1 | +"""Admin workbenches (API 1.1, `x-aia-admin-token`): data-health dashboard, entity resolution, anomalies, extraction debugger, quarantine, | |
| 2 | +audit log, run rollback. Every call is audited (router dependency shared with `admin.py`). Nothing here deletes data.""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import inspect | |
| 6 | +import re | |
| 7 | +from collections import defaultdict | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +from fastapi import APIRouter, Depends, Query, Request | |
| 11 | +from pydantic import BaseModel, Field | |
| 12 | + | |
| 13 | +from aiatlas.api.common import ADMIN_DEPENDENCIES, MAIN_ENTITY_TYPES, STATUS_VOCAB, ApiError, audit, client_ip | |
| 14 | +from aiatlas.api.routers.admin import SNAPSHOT_TEXT_LIMIT, audit_request | |
| 15 | +from aiatlas.db import connection, execute, fetch_all, fetch_one, fetch_val, jsonb, transaction | |
| 16 | +from aiatlas.ids import new_id | |
| 17 | +from aiatlas.ontology.licenses import normalize_license | |
| 18 | +from aiatlas.ontology.models import analyze_model_name, variant_key | |
| 19 | +from aiatlas.ontology.openness import OPENNESS_CATEGORIES | |
| 20 | +from aiatlas.sdk import archive | |
| 21 | +from aiatlas.services import cache | |
| 22 | +from aiatlas.services import merge as merge_service | |
| 23 | +from aiatlas.services.frontier import all_primary_groups, frontier_model_ids, rank_rows | |
| 24 | + | |
| 25 | +router = APIRouter(prefix="/api/v1/admin", tags=["admin-quality"], dependencies=[*ADMIN_DEPENDENCIES, Depends(audit_request)]) | |
| 26 | +SAMPLE = 8 | |
| 27 | +DECISIONS = ("merge", "alias", "variant_of", "family_member", "keep_separate", "defer") | |
| 28 | + | |
| 29 | + | |
| 30 | +async def _count_sample(conn: Any, sql_from_where: str, select: str, order: str = "1", **params: Any) -> dict[str, Any]: | |
| 31 | + count = await fetch_val(conn, f"select count(*) {sql_from_where}", **params) | |
| 32 | + sample = await fetch_all(conn, f"select {select} {sql_from_where} order by {order} limit {SAMPLE}", **params) | |
| 33 | + return {"count": int(count or 0), "sample": sample} | |
| 34 | + | |
| 35 | + | |
| 36 | +# ------------------------------------------------------------------------------------------------------------------ /admin/quality | |
| 37 | + | |
| 38 | + | |
| 39 | +@router.get("/quality") | |
| 40 | +async def quality_dashboard() -> dict[str, Any]: | |
| 41 | + out: dict[str, Any] = {} | |
| 42 | + ent = "e.id, e.slug, e.canonical_name as name, e.entity_type" | |
| 43 | + async with connection() as conn: | |
| 44 | + out["duplicate_candidates"] = { | |
| 45 | + "pending_decisions": await _count_sample(conn, "from resolution_decisions d where d.decision = 'defer' and not d.applied", "d.id, d.a_id, d.b_id, d.note, d.created_at", "d.created_at desc"), | |
| 46 | + "review_merge_candidates": await _count_sample(conn, "from review_queue r where r.kind = 'merge_candidate' and r.status = 'pending'", "r.id, r.entity_ids, r.reason, r.created_at", "r.created_at desc"), | |
| 47 | + } | |
| 48 | + tax_unmapped = await _count_sample(conn, "from taxonomy_mappings t where t.canonical is null", "t.domain, t.raw, t.count, t.last_seen_at", "t.count desc") | |
| 49 | + bad_open = await _count_sample(conn, "from entities e where e.entity_type = 'model' and e.merged_into is null and coalesce(e.attributes->>'openness', '') <> '' and e.attributes->>'openness' <> all(cast(:cats as text[]))", | |
| 50 | + f"{ent}, e.attributes->>'openness' as value", "e.updated_at desc", cats=list(OPENNESS_CATEGORIES)) | |
| 51 | + bad_status = await _count_sample(conn, "from entities e where e.entity_type = 'model' and e.merged_into is null and e.status <> all(cast(:st as text[]))", f"{ent}, e.status as value", "e.updated_at desc", | |
| 52 | + st=[*STATUS_VOCAB, "merged", "archived"]) | |
| 53 | + lic_rows = await fetch_all(conn, "select e.attributes->>'license' as raw, count(*) as n from entities e where e.entity_type = 'model' and e.merged_into is null and e.attributes ? 'license' and not e.attributes ? 'license_key' group by 1") | |
| 54 | + bad_lic = [{"raw": r["raw"], "models": int(r["n"])} for r in lic_rows if r["raw"] and normalize_license(r["raw"]) is None] | |
| 55 | + out["taxonomy_violations"] = {"unmapped_taxonomy_rows": tax_unmapped, "openness_unknown_vocab": bad_open, "status_unknown_vocab": bad_status, | |
| 56 | + "license_unclassified": {"count": sum(x["models"] for x in bad_lic), "sample": sorted(bad_lic, key=lambda x: -x["models"])[:SAMPLE]}} | |
| 57 | + by_check = await fetch_all(conn, "select check_name, severity, count(*) as n from anomalies where status = 'open' group by 1, 2 order by 3 desc") | |
| 58 | + out["impossible_values"] = {"count": sum(int(r["n"]) for r in by_check), "by_check": [{**r, "n": int(r["n"])} for r in by_check], | |
| 59 | + "sample": await fetch_all(conn, "select a.id, a.check_name, a.severity, a.message, a.entity_id, e.slug, a.value, a.last_seen_at from anomalies a left join entities e on e.id = a.entity_id where a.status = 'open' order by case a.severity when 'critical' then 0 when 'warning' then 1 else 2 end, a.last_seen_at desc limit 12")} | |
| 60 | + out["conflicting_t1_claims"] = await _count_sample(conn, "from claims c join entities e on e.id = c.entity_id where c.status = 'conflicting' and c.tier = 1", | |
| 61 | + f"c.id as claim_id, c.property, c.value, c.source_url, {ent}", "c.observed_at desc") | |
| 62 | + base_models = "from entities e where e.entity_type = 'model' and e.merged_into is null" | |
| 63 | + out["models_without_organization"] = await _count_sample(conn, f"{base_models} and e.organization_id is null", ent, "e.updated_at desc") | |
| 64 | + out["models_without_release_source"] = await _count_sample(conn, f"{base_models} and not exists (select 1 from claims c where c.entity_id = e.id and c.property = 'release_date' and c.status = 'current')", ent, "e.updated_at desc") | |
| 65 | + out["models_without_parameters"] = await _count_sample(conn, f"{base_models} and not e.attributes ? 'parameter_count'", ent, "e.updated_at desc") | |
| 66 | + out["orphan_benchmark_results"] = await _count_sample(conn, "from benchmark_results r join entities m on m.id = r.model_id where r.valid_to is null and (m.merged_into is not null or m.entity_type <> 'model')", | |
| 67 | + "r.id as result_id, r.benchmark_id, m.id, m.slug, m.canonical_name as name, m.entity_type, m.merged_into", "r.observed_at desc") | |
| 68 | + out["benchmarks_without_results"] = await _count_sample(conn, "from entities e where e.entity_type = 'benchmark' and e.merged_into is null and not exists (select 1 from benchmark_results r where r.benchmark_id = e.id and r.valid_to is null)", | |
| 69 | + ent, "e.canonical_name") | |
| 70 | + out["unresolved_provider_deployments"] = await _count_sample(conn, """from prices p join entities m on m.id = p.model_id join entities pv on pv.id = p.provider_id where p.valid_to is null and p.provider_model_id is not null | |
| 71 | + and not exists (select 1 from entity_identifiers i where i.entity_id = p.model_id and i.value = p.provider_model_id)""", | |
| 72 | + "p.id as price_id, p.provider_model_id, m.slug as model, pv.slug as provider", "p.observed_at desc") | |
| 73 | + names = await fetch_all(conn, "select e.id, e.slug, e.canonical_name, e.attributes->>'hf_repo' as hf_repo, e.attributes->>'is_quantized' as is_q, e.attributes->>'quant_format' as fmt from entities e where e.entity_type = 'model' and e.merged_into is null") | |
| 74 | + quants = [] | |
| 75 | + for r in names: | |
| 76 | + a = analyze_model_name(r["hf_repo"] or r["canonical_name"]) | |
| 77 | + if a.is_artifact or r["is_q"] == "true": | |
| 78 | + quants.append({"id": r["id"], "slug": r["slug"], "name": r["canonical_name"], "hf_repo": r["hf_repo"], "quant_formats": a.quant_formats or ([r["fmt"]] if r["fmt"] else []), | |
| 79 | + "kind": "quantization" if (a.is_quantized or r["is_q"] == "true") else "conversion"}) | |
| 80 | + out["quantisations_typed_as_models"] = {"count": len(quants), "sample": quants[:SAMPLE]} | |
| 81 | + out["stale_sources"] = await _count_sample(conn, "from connectors c where c.enabled and c.last_success_at is not null and c.last_success_at < now() - make_interval(secs => 3 * c.interval_seconds)", | |
| 82 | + "c.name, c.health, c.last_success_at, c.interval_seconds, c.consecutive_failures", "c.last_success_at") | |
| 83 | + counts = {r["entity_type"]: int(r["n"]) for r in await fetch_all(conn, "select entity_type, count(*) as n from entities where merged_into is null group by 1")} | |
| 84 | + out["empty_public_categories"] = {"count": sum(1 for t in MAIN_ENTITY_TYPES if not counts.get(t)), "sample": [t for t in MAIN_ENTITY_TYPES if not counts.get(t)]} | |
| 85 | + out["quarantined_runs_pending"] = await _count_sample(conn, "from quarantined_runs q where q.status = 'pending'", "q.id, q.run_id, q.connector_name, q.reason, q.stats, q.created_at", "q.created_at desc") | |
| 86 | + out["review_queue_priority"] = await _review_priority(conn) | |
| 87 | + out["note"] = "Counts are live. Nothing is deleted by these checks; they point at review actions (/admin/entity-resolution, /admin/anomalies, /admin/quarantine)." | |
| 88 | + return out | |
| 89 | + | |
| 90 | + | |
| 91 | +async def _review_priority(conn: Any) -> list[dict[str, Any]]: | |
| 92 | + """Homepage-visible items first: frontier models, largest params/context claims, price anomalies, benchmark leaders, major orgs, duplicate canonical models.""" | |
| 93 | + frontier, _ = await frontier_model_ids(conn) | |
| 94 | + groups = await all_primary_groups(conn) | |
| 95 | + leaders = {rank_rows(g["rows"], g["higher_is_better"])[0]["model_id"] for g in groups.values() if g["rows"]} | |
| 96 | + big = {r["id"] for r in await fetch_all(conn, """select id from entities where entity_type = 'model' and merged_into is null and attributes->>'parameter_count' ~ '^[0-9.]+$' | |
| 97 | + order by (attributes->>'parameter_count')::double precision desc limit 20""")} | |
| 98 | + big |= {r["id"] for r in await fetch_all(conn, """select id from entities where entity_type = 'model' and merged_into is null and attributes->>'context_length' ~ '^[0-9]+$' | |
| 99 | + order by (attributes->>'context_length')::bigint desc limit 20""")} | |
| 100 | + major_orgs = {r["organization_id"] for r in await fetch_all(conn, "select organization_id from entities where entity_type = 'model' and merged_into is null and organization_id is not null group by 1 having count(*) >= 10")} | |
| 101 | + anomalies = await fetch_all(conn, """select a.id, a.check_name, a.severity, a.message, a.entity_id, e.slug, e.organization_id, e.entity_type from anomalies a left join entities e on e.id = a.entity_id | |
| 102 | + where a.status = 'open' order by a.last_seen_at desc limit 2000""") | |
| 103 | + merges = await fetch_all(conn, "select r.id, r.entity_ids, r.reason from review_queue r where r.kind = 'merge_candidate' and r.status = 'pending' limit 500") | |
| 104 | + items = [] | |
| 105 | + for a in anomalies: | |
| 106 | + reasons = [] | |
| 107 | + eid = a["entity_id"] | |
| 108 | + if eid in frontier: | |
| 109 | + reasons.append("frontier model") | |
| 110 | + if eid in leaders: | |
| 111 | + reasons.append("benchmark leader") | |
| 112 | + if eid in big: | |
| 113 | + reasons.append("among the largest params/context claims") | |
| 114 | + if a["check_name"] in ("negative_price", "price_too_high", "zero_output_price", "price_jump_100x", "cached_gt_input_price"): | |
| 115 | + reasons.append("price anomaly") | |
| 116 | + if a["organization_id"] in major_orgs: | |
| 117 | + reasons.append("major organization") | |
| 118 | + if reasons or a["severity"] == "critical": | |
| 119 | + items.append({"kind": "anomaly", "id": a["id"], "check": a["check_name"], "severity": a["severity"], "message": a["message"], "entity_id": eid, "slug": a["slug"], "reasons": reasons or ["critical severity"], | |
| 120 | + "_p": (0 if reasons else 1, {"critical": 0, "warning": 1}.get(a["severity"], 2))}) | |
| 121 | + for m in merges: | |
| 122 | + ids = list(m["entity_ids"] or []) | |
| 123 | + hits = [i for i in ids if i in frontier or i in leaders] | |
| 124 | + if hits: | |
| 125 | + items.append({"kind": "merge_candidate", "id": m["id"], "entity_ids": ids, "reason": m["reason"], "reasons": ["duplicate involving a frontier model / benchmark leader"], "_p": (0, 0)}) | |
| 126 | + items.sort(key=lambda x: x["_p"]) | |
| 127 | + for x in items: | |
| 128 | + x.pop("_p") | |
| 129 | + return items[:50] | |
| 130 | + | |
| 131 | + | |
| 132 | +# ------------------------------------------------------------------------------------------------------------------ /admin/entity-resolution | |
| 133 | + | |
| 134 | + | |
| 135 | +async def _side(conn: Any, eid: str) -> dict[str, Any] | None: | |
| 136 | + row = await fetch_one(conn, """select e.id, e.slug, e.canonical_name, e.entity_type, e.attributes, e.first_seen_at, e.merged_into, e.family_id, e.canonical_id, e.identity_confidence, | |
| 137 | + o.slug as org_slug, o.canonical_name as org_name, f.canonical_name as family_name, | |
| 138 | + (select count(*) from relations r where (r.subject_id = e.id or r.object_id = e.id) and r.valid_to is null) as relations, | |
| 139 | + (select count(distinct c.source_id) from claims c where c.entity_id = e.id and c.status = 'current') as sources, | |
| 140 | + (select count(*) from claims c where c.entity_id = e.id and c.status = 'current') as claims, | |
| 141 | + (select count(*) from prices p where p.model_id = e.id and p.valid_to is null) as prices, | |
| 142 | + (select count(*) from benchmark_results r where r.model_id = e.id and r.valid_to is null) as results, | |
| 143 | + (select jsonb_agg(jsonb_build_object('scheme', i.scheme, 'value', i.value) order by i.scheme) from entity_identifiers i where i.entity_id = e.id) as identifiers, | |
| 144 | + (select jsonb_agg(a.alias order by a.alias) from entity_aliases a where a.entity_id = e.id) as aliases | |
| 145 | + from entities e left join entities o on o.id = e.organization_id left join entities f on f.id = e.family_id where e.id = :id or e.slug = :id limit 1""", id=eid) | |
| 146 | + if not row: | |
| 147 | + return None | |
| 148 | + a = row["attributes"] or {} | |
| 149 | + return {"id": row["id"], "slug": row["slug"], "name": row["canonical_name"], "entity_type": row["entity_type"], "organization": row["org_name"], "organization_slug": row["org_slug"], | |
| 150 | + "family": row["family_name"] or a.get("family"), "parameter_count": a.get("parameter_count"), "active_parameter_count": a.get("active_parameter_count"), "release_date": a.get("release_date"), | |
| 151 | + "architecture": a.get("architecture"), "model_type": a.get("model_type"), "hf_repo": a.get("hf_repo"), "openness": a.get("openness"), "context_length": a.get("context_length"), | |
| 152 | + "identifiers": row["identifiers"] or [], "aliases": row["aliases"] or [], "relations": int(row["relations"] or 0), "sources": int(row["sources"] or 0), "claims": int(row["claims"] or 0), | |
| 153 | + "prices": int(row["prices"] or 0), "results": int(row["results"] or 0), "first_seen_at": row["first_seen_at"], "merged_into": row["merged_into"], "canonical_id": row["canonical_id"], | |
| 154 | + "identity_confidence": row["identity_confidence"], "variant_key": variant_key(a.get("hf_repo") or row["canonical_name"]), "name_analysis": _analysis(a.get("hf_repo") or row["canonical_name"])} | |
| 155 | + | |
| 156 | + | |
| 157 | +def _analysis(name: str) -> dict[str, Any]: | |
| 158 | + a = analyze_model_name(name) | |
| 159 | + return {"base_key": a.base_key, "quant_formats": a.quant_formats, "precision": a.precision, "is_artifact": a.is_artifact, "effort": a.effort, "family_hint": a.family_hint, | |
| 160 | + "parameter_count": a.parameter_count, "snapshot_date": a.snapshot_date} | |
| 161 | + | |
| 162 | + | |
| 163 | +@router.get("/entity-resolution") | |
| 164 | +async def entity_resolution(type: str | None = Query("model", alias="type"), status: str = Query("pending"), limit: int = Query(50, ge=1, le=200), threshold: float = Query(0.8, ge=0.3, le=1.0)) -> dict[str, Any]: | |
| 165 | + """Candidate pairs: review merge_candidates + trigram similarity + same `variant_key` (ontology.models) — side by side.""" | |
| 166 | + pairs: dict[tuple[str, str], dict[str, Any]] = {} | |
| 167 | + async with connection() as conn: | |
| 168 | + decided = {(d["a_id"], d["b_id"]): d for d in await fetch_all(conn, "select a_id, b_id, decision, applied, created_at, note from resolution_decisions")} | |
| 169 | + if status in ("pending", "all"): | |
| 170 | + for r in await fetch_all(conn, "select r.id, r.entity_ids, r.reason, r.payload from review_queue r where r.kind = 'merge_candidate' and r.status = 'pending' order by r.created_at desc limit :lim", lim=limit * 2): | |
| 171 | + ids = list(r["entity_ids"] or []) | |
| 172 | + if len(ids) >= 2: | |
| 173 | + pairs.setdefault(tuple(sorted(ids[:2])), {"sources": [], "similarity": None})["sources"].append({"kind": "review_merge_candidate", "review_id": r["id"], "reason": r["reason"]}) | |
| 174 | + where = "a.entity_type = :t" if type else "a.entity_type in ('model','company','organization','lab','provider','benchmark','researcher')" | |
| 175 | + for r in await fetch_all(conn, f"""select a.id as a_id, b.id as b_id, similarity(a.canonical_name, b.canonical_name) as sim from entities a join entities b | |
| 176 | + on b.entity_type = a.entity_type and b.id > a.id and a.canonical_name % b.canonical_name | |
| 177 | + where {where} and a.merged_into is null and b.merged_into is null and similarity(a.canonical_name, b.canonical_name) > :th order by sim desc limit :lim""", | |
| 178 | + t=type, th=threshold, lim=limit * 2): | |
| 179 | + p = pairs.setdefault(tuple(sorted((r["a_id"], r["b_id"]))), {"sources": [], "similarity": None}) | |
| 180 | + p["similarity"] = round(float(r["sim"]), 3) | |
| 181 | + p["sources"].append({"kind": "trigram_similarity", "similarity": round(float(r["sim"]), 3)}) | |
| 182 | + if type in (None, "model"): | |
| 183 | + names = await fetch_all(conn, "select id, canonical_name, attributes->>'hf_repo' as hf from entities where entity_type = 'model' and merged_into is null") | |
| 184 | + by_key: dict[str, list[str]] = defaultdict(list) | |
| 185 | + for n in names: | |
| 186 | + k = variant_key(n["hf"] or n["canonical_name"]) | |
| 187 | + if k: | |
| 188 | + by_key[k].append(n["id"]) | |
| 189 | + for k, ids in by_key.items(): | |
| 190 | + if 2 <= len(ids) <= 6: | |
| 191 | + for i in range(len(ids)): | |
| 192 | + for j in range(i + 1, len(ids)): | |
| 193 | + pairs.setdefault(tuple(sorted((ids[i], ids[j]))), {"sources": [], "similarity": None})["sources"].append({"kind": "same_variant_key", "variant_key": k}) | |
| 194 | + items = [] | |
| 195 | + for (a, b), p in pairs.items(): | |
| 196 | + d = decided.get((a, b)) or decided.get((b, a)) | |
| 197 | + if status == "pending" and d and d["decision"] != "defer": | |
| 198 | + continue | |
| 199 | + if status == "decided" and not d: | |
| 200 | + continue | |
| 201 | + sa, sb = await _side(conn, a), await _side(conn, b) | |
| 202 | + if not sa or not sb: | |
| 203 | + continue | |
| 204 | + items.append({"a": sa, "b": sb, "signals": p["sources"], "similarity": p["similarity"], "same_variant_key": sa["variant_key"] == sb["variant_key"] and bool(sa["variant_key"]), | |
| 205 | + "same_organization": sa["organization_slug"] == sb["organization_slug"] and sa["organization_slug"] is not None, "decision": d, | |
| 206 | + "hint": ("artifact of the other" if sa["name_analysis"]["is_artifact"] != sb["name_analysis"]["is_artifact"] else | |
| 207 | + "effort variant" if (sa["name_analysis"]["effort"] or sb["name_analysis"]["effort"]) else "possible duplicate")}) | |
| 208 | + if len(items) >= limit: | |
| 209 | + break | |
| 210 | + items.sort(key=lambda x: (-len(x["signals"]), -(x["similarity"] or 0))) | |
| 211 | + return {"items": items, "total": len(items), "threshold": threshold, "status": status, "decisions": list(DECISIONS), | |
| 212 | + "note": "Signals are independent (review queue, trigram similarity, shared variant_key); nothing is merged until POST /admin/entity-resolution/{a}/{b}."} | |
| 213 | + | |
| 214 | + | |
| 215 | +class ResolutionBody(BaseModel): | |
| 216 | + decision: str = Field(..., pattern="^(merge|alias|variant_of|family_member|keep_separate|defer)$") | |
| 217 | + note: str | None = Field(None, max_length=2000) | |
| 218 | + | |
| 219 | + | |
| 220 | +@router.post("/entity-resolution/{a}/{b}") | |
| 221 | +async def resolve_pair(a: str, b: str, body: ResolutionBody, request: Request) -> dict[str, Any]: | |
| 222 | + """Persist the decision, then apply it (merge_entities with `mode=` when the service supports it). `a` is folded INTO `b`.""" | |
| 223 | + applied = False | |
| 224 | + effect: dict[str, Any] | None = None | |
| 225 | + async with transaction() as conn: | |
| 226 | + ea = await fetch_one(conn, "select id, entity_type, canonical_name, merged_into from entities where id = :k or slug = :k limit 1", k=a) | |
| 227 | + eb = await fetch_one(conn, "select id, entity_type, canonical_name, merged_into from entities where id = :k or slug = :k limit 1", k=b) | |
| 228 | + if not ea or not eb: | |
| 229 | + raise ApiError(404, "entity a or b not found") | |
| 230 | + if ea["id"] == eb["id"]: | |
| 231 | + raise ApiError(400, "a and b are the same entity") | |
| 232 | + did = new_id("review") | |
| 233 | + sig = inspect.signature(merge_service.merge_entities) | |
| 234 | + supports_mode = "mode" in sig.parameters | |
| 235 | + try: | |
| 236 | + if body.decision == "merge": | |
| 237 | + effect = await (merge_service.merge_entities(conn, ea["id"], eb["id"], mode="merge") if supports_mode else merge_service.merge_entities(conn, ea["id"], eb["id"])) | |
| 238 | + applied = True | |
| 239 | + elif body.decision == "variant_of": | |
| 240 | + if supports_mode: | |
| 241 | + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"], mode="variant_of") | |
| 242 | + else: | |
| 243 | + await execute(conn, "update entities set canonical_id = :b where id = :a", a=ea["id"], b=eb["id"]) | |
| 244 | + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"]) | |
| 245 | + effect["fallback"] = "canonical_id set then folded with merge_entities (service has no mode= yet)" | |
| 246 | + applied = True | |
| 247 | + elif body.decision == "alias": | |
| 248 | + if supports_mode: | |
| 249 | + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"], mode="alias") | |
| 250 | + else: | |
| 251 | + from aiatlas.ids import normalize_alias | |
| 252 | + | |
| 253 | + await execute(conn, "insert into entity_aliases (entity_id, alias, alias_norm, kind) values (:t, :al, :n, 'alias') on conflict (entity_id, alias_norm) do nothing", | |
| 254 | + t=eb["id"], al=ea["canonical_name"], n=normalize_alias(ea["canonical_name"])) | |
| 255 | + effect = {"alias_added": ea["canonical_name"], "to": eb["id"], "fallback": "alias row only (service has no mode= yet); entities kept separate"} | |
| 256 | + applied = True | |
| 257 | + elif body.decision == "family_member": | |
| 258 | + if supports_mode: | |
| 259 | + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"], mode="family_member") | |
| 260 | + applied = True | |
| 261 | + elif eb["entity_type"] == "model_family": | |
| 262 | + await execute(conn, "update entities set family_id = :b, updated_at = now() where id = :a", a=ea["id"], b=eb["id"]) | |
| 263 | + effect = {"family_id": eb["id"]} | |
| 264 | + applied = True | |
| 265 | + else: | |
| 266 | + raise ApiError(400, "family_member requires b to be a model_family entity") | |
| 267 | + except (ValueError, LookupError) as exc: | |
| 268 | + raise ApiError(400, str(exc)) from exc | |
| 269 | + await execute(conn, """insert into resolution_decisions (id, a_id, b_id, decision, actor, note, payload, applied) values (:id, :a, :b, :d, 'admin', :n, cast(:p as jsonb), :ap) | |
| 270 | + on conflict (a_id, b_id, decision) do update set note = excluded.note, payload = excluded.payload, applied = excluded.applied, created_at = now()""", | |
| 271 | + id=did, a=ea["id"], b=eb["id"], d=body.decision, n=body.note, p=jsonb({"effect": effect, "mode_supported": supports_mode}), ap=applied) | |
| 272 | + if applied: | |
| 273 | + await execute(conn, "update review_queue set status = 'approved', resolved_at = now(), resolution = cast(:r as jsonb) where kind = 'merge_candidate' and status = 'pending' and entity_ids @> cast(:ids as text[])", | |
| 274 | + r=jsonb({"via": "entity-resolution", "decision": body.decision}), ids=[ea["id"], eb["id"]]) | |
| 275 | + if applied: | |
| 276 | + await cache.cache_invalidate() | |
| 277 | + await audit("entity-resolution", f"{ea['id']}→{eb['id']}", {"decision": body.decision, "applied": applied, "effect": effect}, client_ip(request)) | |
| 278 | + return {"ok": True, "a": ea["id"], "b": eb["id"], "decision": body.decision, "applied": applied, "effect": effect} | |
| 279 | + | |
| 280 | + | |
| 281 | +# ------------------------------------------------------------------------------------------------------------------ /admin/anomalies | |
| 282 | + | |
| 283 | + | |
| 284 | +@router.get("/anomalies") | |
| 285 | +async def anomalies(status: str = Query("open"), severity: str | None = None, check: str | None = None, limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0)) -> dict[str, Any]: | |
| 286 | + where = ["true"] if status == "all" else ["a.status = :st"] | |
| 287 | + params: dict[str, Any] = {"st": status, "lim": limit, "off": offset} | |
| 288 | + if severity: | |
| 289 | + where.append("a.severity = :sev") | |
| 290 | + params["sev"] = severity | |
| 291 | + if check: | |
| 292 | + where.append("a.check_name = :chk") | |
| 293 | + params["chk"] = check | |
| 294 | + w = " and ".join(where) | |
| 295 | + async with connection() as conn: | |
| 296 | + rows = await fetch_all(conn, f"""select a.*, e.slug, e.canonical_name as entity_name, e.entity_type from anomalies a left join entities e on e.id = a.entity_id where {w} | |
| 297 | + order by case a.severity when 'critical' then 0 when 'warning' then 1 else 2 end, a.last_seen_at desc limit :lim offset :off""", **params) | |
| 298 | + total = await fetch_val(conn, f"select count(*) from anomalies a where {w}", **{k: v for k, v in params.items() if k not in ("lim", "off")}) | |
| 299 | + by = await fetch_all(conn, "select check_name, severity, status, count(*) as n from anomalies group by 1, 2, 3 order by 4 desc") | |
| 300 | + return {"items": rows, "total": int(total or 0), "limit": limit, "offset": offset, "by_check": [{**r, "n": int(r["n"])} for r in by]} | |
| 301 | + | |
| 302 | + | |
| 303 | +class AnomalyBody(BaseModel): | |
| 304 | + status: str = Field(..., pattern="^(resolved|ignored|open)$") | |
| 305 | + note: str | None = Field(None, max_length=2000) | |
| 306 | + | |
| 307 | + | |
| 308 | +@router.post("/anomalies/{anomaly_id}") | |
| 309 | +async def anomaly_action(anomaly_id: str, body: AnomalyBody) -> dict[str, Any]: | |
| 310 | + async with transaction() as conn: | |
| 311 | + row = await fetch_one(conn, "update anomalies set status = :st, resolution = :n, resolved_at = case when :st = 'open' then null else now() end where id = :id returning id, check_name, status", | |
| 312 | + st=body.status, n=body.note, id=anomaly_id) | |
| 313 | + if not row: | |
| 314 | + raise ApiError(404, "anomaly not found") | |
| 315 | + return {"ok": True, **row} | |
| 316 | + | |
| 317 | + | |
| 318 | +# ------------------------------------------------------------------------------------------------------------------ /admin/extractions/{snapshot_id} | |
| 319 | + | |
| 320 | +_NUM_FORMATS = (lambda v: str(v), lambda v: f"{int(v):,}" if float(v).is_integer() else f"{v:,}", lambda v: f"{int(v)}" if float(v).is_integer() else f"{v}", | |
| 321 | + lambda v: f"{v / 1e9:g}B" if abs(v) >= 1e9 else f"{v / 1e6:g}M" if abs(v) >= 1e6 else f"{v / 1e3:g}K" if abs(v) >= 1e3 else str(v), | |
| 322 | + lambda v: f"{v / 1e9:.1f}B" if abs(v) >= 1e9 else f"{v / 1e6:.1f}M" if abs(v) >= 1e6 else f"{v / 1e3:.0f}K" if abs(v) >= 1e3 else str(v), | |
| 323 | + lambda v: f"{v / 1e3:g}k" if abs(v) >= 1e3 else str(v), lambda v: f"${v:g}", lambda v: f"${v:.2f}") | |
| 324 | + | |
| 325 | + | |
| 326 | +def _locate(text: str, value: Any) -> dict[str, Any]: | |
| 327 | + cands: list[str] = [] | |
| 328 | + if isinstance(value, bool): | |
| 329 | + cands = [str(value).lower()] | |
| 330 | + elif isinstance(value, (int, float)): | |
| 331 | + for f in _NUM_FORMATS: | |
| 332 | + try: | |
| 333 | + cands.append(f(float(value))) | |
| 334 | + except Exception: # noqa: BLE001 | |
| 335 | + pass | |
| 336 | + elif isinstance(value, str) and value.strip(): | |
| 337 | + cands = [value.strip()] | |
| 338 | + for c in dict.fromkeys(cands): | |
| 339 | + m = re.search(re.escape(c), text, flags=re.IGNORECASE) | |
| 340 | + if m: | |
| 341 | + s, e = max(0, m.start() - 80), min(len(text), m.end() + 80) | |
| 342 | + return {"found": True, "offset": m.start(), "match": text[m.start():m.end()], "context": text[s:e]} | |
| 343 | + return {"found": False, "tried": cands[:6]} | |
| 344 | + | |
| 345 | + | |
| 346 | +@router.get("/extractions/{snap_id}") | |
| 347 | +async def extraction_debugger(snap_id: str, text_limit: int = Query(SNAPSHOT_TEXT_LIMIT, ge=0, le=SNAPSHOT_TEXT_LIMIT)) -> dict[str, Any]: | |
| 348 | + async with connection() as conn: | |
| 349 | + snap = await fetch_one(conn, """select s.id, s.document_id, s.run_id, s.url, s.final_url, s.observed_at, s.http_status, s.content_type, s.content_hash, s.byte_size, s.text_hash, s.structured, s.diff, | |
| 350 | + s.parser_version, s.connector_version, s.transport, s.changed, s.processing_status, s.raw_path is not null as has_raw, s.text_path, d.url as document_url, | |
| 351 | + d.doc_type, d.connector_name, d.entity_id, d.title, e.slug as entity_slug, e.canonical_name as entity_name | |
| 352 | + from snapshots s join documents d on d.id = s.document_id left join entities e on e.id = d.entity_id where s.id = :id""", id=snap_id) | |
| 353 | + if not snap: | |
| 354 | + raise ApiError(404, "snapshot not found") | |
| 355 | + prev = await fetch_one(conn, "select id, observed_at, content_hash from snapshots where document_id = :d and observed_at < :t and changed order by observed_at desc limit 1", d=snap["document_id"], t=snap["observed_at"]) | |
| 356 | + claims = await fetch_all(conn, "select c.id, c.entity_id, e.slug as entity_slug, c.property, c.value, c.value_raw, c.unit, c.status, c.confidence, c.extractor, c.tier, c.observed_at from claims c left join entities e on e.id = c.entity_id where c.snapshot_id = :id order by e.slug, c.property limit 1000", id=snap_id) | |
| 357 | + relations = await fetch_all(conn, "select r.id, r.subject_id, r.predicate, r.object_id, r.attributes, r.valid_to from relations r where r.snapshot_id = :id limit 500", id=snap_id) | |
| 358 | + results = await fetch_all(conn, "select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.config, r.config_key, r.trust_level, r.valid_to from benchmark_results r where r.snapshot_id = :id limit 500", id=snap_id) | |
| 359 | + prices = await fetch_all(conn, "select p.id, p.model_id, p.provider_id, p.provider_model_id, p.input_per_mtok, p.output_per_mtok, p.valid_from, p.valid_to from prices p where p.snapshot_id = :id limit 500", id=snap_id) | |
| 360 | + events = await fetch_all(conn, "select id, entity_id, event_type, category, property, summary, importance, occurred_at, is_backfill, group_key from change_events where snapshot_id = :id order by occurred_at desc limit 300", id=snap_id) | |
| 361 | + llm = await fetch_all(conn, "select id, task_type, stage, model, status, input_tokens, output_tokens, duration_ms, error, created_at from llm_jobs where snapshot_id = :id order by created_at desc limit 50", id=snap_id) | |
| 362 | + touched = {c["entity_id"] for c in claims} | {r["subject_id"] for r in relations} | {r["object_id"] for r in relations} | {r["model_id"] for r in results} | {p["model_id"] for p in prices} | |
| 363 | + touched.discard(None) | |
| 364 | + cands = await fetch_all(conn, "select id, slug, canonical_name, entity_type, merged_into, identity_confidence from entities where id = any(cast(:ids as text[])) order by canonical_name", ids=list(touched)) if touched else [] | |
| 365 | + text, text_error, text_len = None, None, 0 | |
| 366 | + if snap.get("text_path"): | |
| 367 | + try: | |
| 368 | + full = archive.load_text(snap["text_path"]) | |
| 369 | + text_len = len(full) | |
| 370 | + text = full[:text_limit] | |
| 371 | + except OSError as exc: | |
| 372 | + text_error = f"cleaned text unavailable ({exc.__class__.__name__})" | |
| 373 | + full = "" | |
| 374 | + else: | |
| 375 | + full = "" | |
| 376 | + spans = [] | |
| 377 | + for c in claims: | |
| 378 | + v = c["value"] | |
| 379 | + if isinstance(v, (int, float, str, bool)) and not (isinstance(v, str) and len(v) > 200): | |
| 380 | + spans.append({"claim_id": c["id"], "property": c["property"], "value": v, **(_locate(full, v) if full else {"found": False, "reason": "no cleaned text"})}) | |
| 381 | + out = {k: v for k, v in snap.items() if k != "text_path"} | |
| 382 | + out.update({"has_text": bool(snap.get("text_path")), "text": text, "text_chars": text_len, "text_truncated": text_len > text_limit, "text_error": text_error, | |
| 383 | + "previous_snapshot": prev, "diff": snap.get("diff"), "claims": claims, "relations": relations, "results": results, "prices": prices, "events": events, "llm_jobs": llm, | |
| 384 | + "entity_candidates": cands, "spans": spans, "spans_found": sum(1 for s in spans if s.get("found")), | |
| 385 | + "note": "spans are a best-effort textual search of each claim value in the cleaned text (several number formats tried); not-found is reported honestly, never inferred."}) | |
| 386 | + return out | |
| 387 | + | |
| 388 | + | |
| 389 | +# ------------------------------------------------------------------------------------------------------------------ /admin/quarantine | |
| 390 | + | |
| 391 | + | |
| 392 | +@router.get("/quarantine") | |
| 393 | +async def quarantine(status: str = Query("pending"), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0)) -> dict[str, Any]: | |
| 394 | + where = "true" if status == "all" else "q.status = :st" | |
| 395 | + async with connection() as conn: | |
| 396 | + rows = await fetch_all(conn, f"select q.id, q.run_id, q.connector_name, q.reason, q.stats, q.status, q.created_at, q.resolved_at, q.resolved_by, jsonb_array_length(q.facts) as facts from quarantined_runs q where {where} order by q.created_at desc limit :lim offset :off", | |
| 397 | + st=status, lim=limit, off=offset) | |
| 398 | + total = await fetch_val(conn, f"select count(*) from quarantined_runs q where {where}", st=status) | |
| 399 | + return {"items": rows, "total": int(total or 0), "limit": limit, "offset": offset} | |
| 400 | + | |
| 401 | + | |
| 402 | +class QuarantineBody(BaseModel): | |
| 403 | + action: str = Field(..., pattern="^(release|discard)$") | |
| 404 | + note: str | None = Field(None, max_length=2000) | |
| 405 | + | |
| 406 | + | |
| 407 | +@router.post("/quarantine/{quarantine_id}") | |
| 408 | +async def quarantine_action(quarantine_id: str, body: QuarantineBody) -> dict[str, Any]: | |
| 409 | + try: | |
| 410 | + from aiatlas.services import canonical | |
| 411 | + except ImportError: | |
| 412 | + canonical = None # type: ignore[assignment] | |
| 413 | + fn = getattr(canonical, f"{body.action}_quarantine", None) if canonical else None | |
| 414 | + if fn is None: | |
| 415 | + raise ApiError(501, f"quarantine {body.action} is not available yet: services.canonical.{body.action}_quarantine is missing (Stream A)") | |
| 416 | + async with transaction() as conn: | |
| 417 | + row = await fetch_one(conn, "select * from quarantined_runs where id = :id for update", id=quarantine_id) | |
| 418 | + if not row: | |
| 419 | + raise ApiError(404, "quarantined run not found") | |
| 420 | + if row["status"] != "pending": | |
| 421 | + raise ApiError(409, f"quarantined run already {row['status']}") | |
| 422 | + result = await fn(conn, quarantine_id) if inspect.iscoroutinefunction(fn) else fn(conn, quarantine_id) | |
| 423 | + await execute(conn, "update quarantined_runs set status = :st, resolved_at = now(), resolved_by = 'admin' where id = :id and status = 'pending'", | |
| 424 | + st="released" if body.action == "release" else "discarded", id=quarantine_id) | |
| 425 | + await cache.cache_invalidate() | |
| 426 | + return {"ok": True, "id": quarantine_id, "action": body.action, "result": result} | |
| 427 | + | |
| 428 | + | |
| 429 | +# ------------------------------------------------------------------------------------------------------------------ /admin/audit · rollback | |
| 430 | + | |
| 431 | + | |
| 432 | +@router.get("/audit") | |
| 433 | +async def audit_log(limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), action: str | None = None) -> dict[str, Any]: | |
| 434 | + where = "action ilike :a" if action else "true" | |
| 435 | + async with connection() as conn: | |
| 436 | + rows = await fetch_all(conn, f"select id, actor, action, target, payload, ip, created_at from admin_audit_log where {where} order by id desc limit :lim offset :off", a=f"%{action}%" if action else None, lim=limit, off=offset) | |
| 437 | + total = await fetch_val(conn, f"select count(*) from admin_audit_log where {where}", a=f"%{action}%" if action else None) | |
| 438 | + return {"items": rows, "total": int(total or 0), "limit": limit, "offset": offset} | |
| 439 | + | |
| 440 | + | |
| 441 | +@router.post("/runs/{run_id}/rollback") | |
| 442 | +async def rollback_run(run_id: str, request: Request) -> dict[str, Any]: | |
| 443 | + """Undo one connector run WITHOUT deleting: retract its claims, close its relations/prices/results, flag its events as back-fill (meta.rolled_back).""" | |
| 444 | + async with transaction() as conn: | |
| 445 | + run = await fetch_one(conn, "select id, connector_name, status from connector_runs where id = :id", id=run_id) | |
| 446 | + n: dict[str, int] = {} | |
| 447 | + n["claims_retracted"] = int(await fetch_val(conn, "with u as (update claims set status = 'retracted', valid_to = coalesce(valid_to, now()) where run_id = :r and status <> 'retracted' returning 1) select count(*) from u", r=run_id) or 0) | |
| 448 | + n["relations_closed"] = int(await fetch_val(conn, "with u as (update relations set valid_to = now() where run_id = :r and valid_to is null returning 1) select count(*) from u", r=run_id) or 0) | |
| 449 | + n["prices_closed"] = int(await fetch_val(conn, "with u as (update prices set valid_to = now() where run_id = :r and valid_to is null returning 1) select count(*) from u", r=run_id) or 0) | |
| 450 | + n["results_closed"] = int(await fetch_val(conn, "with u as (update benchmark_results set valid_to = now(), is_current = false where run_id = :r and valid_to is null returning 1) select count(*) from u", r=run_id) or 0) | |
| 451 | + n["events_flagged"] = int(await fetch_val(conn, """with u as (update change_events set is_backfill = true, meta = meta || '{"rolled_back": true}'::jsonb where run_id = :r | |
| 452 | + and not coalesce((meta->>'rolled_back')::boolean, false) returning 1) select count(*) from u""", r=run_id) or 0) | |
| 453 | + # re-materialise attributes whose current claim was retracted: fall back to the best remaining claim | |
| 454 | + affected = await fetch_all(conn, "select distinct entity_id, property from claims where run_id = :r and status = 'retracted'", r=run_id) | |
| 455 | + restored = 0 | |
| 456 | + for a in affected: | |
| 457 | + prev = await fetch_one(conn, "select id, value from claims where entity_id = :e and property = :p and status in ('superseded','current') order by tier, valid_from desc limit 1", e=a["entity_id"], p=a["property"]) | |
| 458 | + if prev: | |
| 459 | + await execute(conn, "update claims set status = 'current', valid_to = null where id = :id", id=prev["id"]) | |
| 460 | + await execute(conn, "update entities set attributes = attributes || jsonb_build_object(:p, cast(:v as jsonb)), updated_at = now() where id = :e", p=a["property"], v=jsonb(prev["value"]), e=a["entity_id"]) | |
| 461 | + restored += 1 | |
| 462 | + else: | |
| 463 | + await execute(conn, "update entities set attributes = attributes - :p, provenance = provenance - :p, updated_at = now() where id = :e", p=a["property"], e=a["entity_id"]) | |
| 464 | + n["attributes_restored"] = restored | |
| 465 | + if run: | |
| 466 | + await execute(conn, "update connector_runs set meta = meta || cast(:m as jsonb) where id = :id", m=jsonb({"rolled_back": True, "rollback_counts": n}), id=run_id) | |
| 467 | + if not run and not any(n.values()): | |
| 468 | + raise ApiError(404, f"no run or facts found for run_id {run_id!r}") | |
| 469 | + await cache.cache_invalidate() | |
| 470 | + await audit("rollback", run_id, {"counts": n, "connector": run["connector_name"] if run else None}, client_ip(request)) | |
| 471 | + return {"ok": True, "run_id": run_id, "connector": run["connector_name"] if run else None, "counts": n, "note": "nothing deleted: claims retracted, live rows closed with valid_to, events flagged is_backfill + meta.rolled_back"} | |
| 472 | + | |
| 473 | + | |
| 474 | +__all__ = ["DECISIONS", "router"] | |
modified
src/aiatlas/api/routers/benchmarks.py
+223 −30
@@ -1,83 +1,276 @@ | ||
| 1 | −"""/benchmarks listing, /benchmarks/{slug}, /benchmarks/{slug}/results (leaderboard), /benchmarks/{slug}/history.""" | |
| 1 | +"""/benchmarks · /benchmarks/{slug} · /benchmarks/{slug}/results · /benchmarks/{slug}/leaderboard · /benchmarks/{slug}/history · | |
| 2 | +/benchmarks/{slug}/frontier · /benchmarks/matrix. | |
| 3 | + | |
| 4 | +API 1.1: results are organised in comparability GROUPS (canonical metric × config_key); leaderboards are ONE row per canonical model (best | |
| 5 | +current row inside the chosen group); benchmarks resolve by slug, alias or id; every benchmark exposes `family`, `variant`, `metric`, | |
| 6 | +`direction`, `groups`, `trust_mix`.""" | |
| 2 | 7 | from __future__ import annotations |
| 3 | 8 | |
| 9 | +from collections import defaultdict | |
| 10 | +from datetime import UTC, datetime | |
| 4 | 11 | from typing import Any |
| 5 | 12 | |
| 6 | 13 | from fastapi import APIRouter, Query, Request |
| 7 | 14 | |
| 8 | 15 | from aiatlas.api.common import ( |
| 9 | − ENTITY_COLS, | |
| 10 | − ENTITY_FROM, | |
| 11 | 16 | PAGINATION, |
| 12 | 17 | RESULT_COLS, |
| 13 | 18 | RESULT_FROM, |
| 19 | + ApiError, | |
| 14 | 20 | Pagination, |
| 15 | 21 | cached, |
| 16 | − entity_cols, | |
| 22 | + csv, | |
| 17 | 23 | entity_summary, |
| 18 | 24 | page, |
| 25 | + parse_ts, | |
| 19 | 26 | resolve_entity, |
| 20 | 27 | resolve_id, |
| 21 | 28 | result_row, |
| 22 | 29 | ) |
| 23 | −from aiatlas.api.detail import leaderboard | |
| 30 | +from aiatlas.api.detail import leaderboard as legacy_leaderboard | |
| 24 | 31 | from aiatlas.api.routers.entities import detail_for_type |
| 25 | 32 | from aiatlas.db import connection, fetch_all, fetch_val |
| 33 | +from aiatlas.ontology.benchmarks import TRUST_LABELS, comparability | |
| 34 | +from aiatlas.services.frontier import ( | |
| 35 | + all_primary_groups, | |
| 36 | + benchmark_meta, | |
| 37 | + frontier_series, | |
| 38 | + group_rows, | |
| 39 | + group_summary, | |
| 40 | + leaderboard_rows, | |
| 41 | + load_results, | |
| 42 | + primary_group, | |
| 43 | + rank_rows, | |
| 44 | +) | |
| 26 | 45 | |
| 27 | 46 | router = APIRouter(prefix="/api/v1/benchmarks", tags=["benchmarks"]) |
| 47 | +GROUPING_NOTE = ("Rows are grouped by comparability group = canonical metric × config_key (hash of the task-defining configuration keys: variant, " | |
| 48 | + "evaluator, harness, shots, pass regime…). A leaderboard shows one row per canonical model: its best current row inside the group. " | |
| 49 | + "Reasoning effort, temperature or judge differences keep rows in the same group but mark them partially comparable.") | |
| 50 | + | |
| 51 | + | |
| 52 | +async def _resolve_benchmark(conn: Any, slug: str) -> dict[str, Any]: | |
| 53 | + return await resolve_entity(conn, slug, ("benchmark",), aliases=True) | |
| 54 | + | |
| 55 | + | |
| 56 | +def _pick_group(groups: list[dict[str, Any]], attrs: dict[str, Any] | None, *, metric: str | None, config_key: str | None) -> dict[str, Any] | None: | |
| 57 | + cands = groups | |
| 58 | + if metric: | |
| 59 | + m = metric.strip().lower() | |
| 60 | + cands = [g for g in cands if g["metric"] == m or (g["metric"] or "").lower() == m] | |
| 61 | + if config_key: | |
| 62 | + cands = [g for g in cands if g["config_key"] == config_key] | |
| 63 | + if metric or config_key: | |
| 64 | + return max(cands, key=lambda g: (g["model_count"], g["n"])) if cands else None | |
| 65 | + return primary_group(attrs, groups) | |
| 66 | + | |
| 67 | + | |
| 68 | +# ------------------------------------------------------------------------------------------------------------------ listing | |
| 28 | 69 | |
| 29 | 70 | |
| 30 | 71 | @router.get("") |
| 31 | 72 | @cached(300) |
| 32 | 73 | async def list_benchmarks(request: Request, category: str | None = None) -> dict[str, Any]: |
| 33 | − where = "e.entity_type = 'benchmark' and e.merged_into is null" + (" and e.attributes->>'category' ilike :cat" if category else "") | |
| 34 | 74 | async with connection() as conn: |
| 35 | − rows = await fetch_all(conn, f""" | |
| 36 | − select {ENTITY_COLS}, | |
| 37 | − (select count(*) from benchmark_results r where r.benchmark_id = e.id and r.valid_to is null) as result_count, | |
| 38 | − (select count(distinct r.model_id) from benchmark_results r where r.benchmark_id = e.id and r.valid_to is null) as model_count, | |
| 39 | − top.score as top_score, {entity_cols("tm", "t_")} | |
| 40 | − from {ENTITY_FROM} | |
| 41 | − left join lateral (select r.model_id, r.score from benchmark_results r where r.benchmark_id = e.id and r.valid_to is null | |
| 42 | − order by case when r.higher_is_better then -r.score else r.score end limit 1) top on true | |
| 43 | − left join entities tm on tm.id = top.model_id left join entities tmo on tmo.id = tm.organization_id | |
| 44 | − where {where} order by result_count desc, e.canonical_name""", cat=category) | |
| 45 | − items = [] | |
| 75 | + meta = await benchmark_meta(conn) | |
| 76 | + rows = await load_results(conn) | |
| 77 | + by_bench: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 46 | 78 | for r in rows: |
| 47 | − top_model = entity_summary(r, "t_") | |
| 48 | − items.append({**(entity_summary(r) or {}), "result_count": int(r["result_count"] or 0), "model_count": int(r["model_count"] or 0), | |
| 49 | − "top": {"model": top_model, "score": r["top_score"]} if top_model else None}) | |
| 50 | − return {"items": items} | |
| 79 | + by_bench[r["benchmark_id"]].append(r) | |
| 80 | + items = [] | |
| 81 | + for bid, m in meta.items(): | |
| 82 | + if category and (m.get("category") or "").lower() != category.lower(): | |
| 83 | + continue | |
| 84 | + brows = by_bench.get(bid, []) | |
| 85 | + groups = list(group_rows(brows).values()) | |
| 86 | + pg = primary_group(m["attributes"], groups) | |
| 87 | + leader = None | |
| 88 | + second = None | |
| 89 | + if pg: | |
| 90 | + ranked = rank_rows(pg["rows"], pg["higher_is_better"]) | |
| 91 | + if ranked: | |
| 92 | + lb = leaderboard_rows(pg) | |
| 93 | + leader = lb[0] if lb else None | |
| 94 | + second = lb[1] if len(lb) > 1 else None | |
| 95 | + trust_mix: dict[str, int] = defaultdict(int) | |
| 96 | + for r in brows: | |
| 97 | + trust_mix[r["trust_level"]] += 1 | |
| 98 | + items.append({ | |
| 99 | + "id": bid, "entity_type": "benchmark", "slug": m["slug"], "name": m["name"], "category": m.get("category"), "family": m.get("family"), "variant": m.get("variant"), | |
| 100 | + "metric": m.get("metric"), "unit": m.get("unit"), "direction": m.get("direction"), "attributes": m["attributes"], | |
| 101 | + "result_count": len(brows), "model_count": len({r["model_id"] for r in brows}), | |
| 102 | + "leader": leader, "second": second, "top": {"model": leader["model"], "score": leader["score"]} if leader else None, | |
| 103 | + "primary_group": group_summary(pg) if pg else None, "groups": sorted((group_summary(g) for g in groups), key=lambda g: (-g["model_count"], g["label"])), | |
| 104 | + "trust_mix": dict(trust_mix), "trust_labels": {k: TRUST_LABELS.get(k, k) for k in trust_mix}, | |
| 105 | + }) | |
| 106 | + items.sort(key=lambda x: (-x["result_count"], x["name"])) | |
| 107 | + return {"items": items, "total": len(items), "note": GROUPING_NOTE} | |
| 108 | + | |
| 109 | + | |
| 110 | +@router.get("/matrix") | |
| 111 | +@cached(300) | |
| 112 | +async def matrix(request: Request, benchmarks: str | None = None, models: str | None = None, org: str | None = None, family: str | None = None, | |
| 113 | + limit: int = Query(60, ge=1, le=300), comparable_only: int = Query(0, ge=0, le=1), min_cells: int = Query(3, ge=1, le=30)) -> dict[str, Any]: | |
| 114 | + """Rows = canonical models, columns = benchmarks, cell = best current score in the primary comparability group (+ trust, config_key).""" | |
| 115 | + async with connection() as conn: | |
| 116 | + groups = await all_primary_groups(conn) | |
| 117 | + meta = await benchmark_meta(conn) | |
| 118 | + wanted_b: list[str] | None = None | |
| 119 | + if benchmarks: | |
| 120 | + wanted_b = [] | |
| 121 | + for key in csv(benchmarks): | |
| 122 | + b = await _resolve_benchmark(conn, key) | |
| 123 | + wanted_b.append(b["id"]) | |
| 124 | + wanted_m: set[str] | None = None | |
| 125 | + if models: | |
| 126 | + wanted_m = {await resolve_id(conn, k, ("model",)) or "" for k in csv(models)} | |
| 127 | + if org: | |
| 128 | + rows = await fetch_all(conn, "select e.id from entities e join entities o on o.id = e.organization_id where e.entity_type = 'model' and e.merged_into is null and (o.slug = :o or o.id = :o or o.canonical_name ilike :o)", o=org) | |
| 129 | + wanted_m = (wanted_m or set()) | {r["id"] for r in rows} | |
| 130 | + if family: | |
| 131 | + rows = await fetch_all(conn, "select e.id from entities e left join entities f on f.id = e.family_id where e.entity_type = 'model' and e.merged_into is null and (f.slug = :f or f.id = :f or e.attributes->>'family' ilike :f)", f=family) | |
| 132 | + wanted_m = (wanted_m or set()) | {r["id"] for r in rows} | |
| 133 | + if wanted_b is None: | |
| 134 | + wanted_b = [bid for bid, _ in sorted(groups.items(), key=lambda kv: -kv[1]["n"])[:12]] | |
| 135 | + columns = [] | |
| 136 | + cells: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) | |
| 137 | + model_ref: dict[str, dict[str, Any]] = {} | |
| 138 | + for bid in wanted_b: | |
| 139 | + g = groups.get(bid) | |
| 140 | + m = meta.get(bid) or {"id": bid, "slug": None, "name": None} | |
| 141 | + columns.append({"id": bid, "slug": m.get("slug"), "name": m.get("name"), "category": m.get("category"), "metric": g["metric"] if g else m.get("metric"), | |
| 142 | + "config_key": g["config_key"] if g else None, "group_label": g["label"] if g else None, "higher_is_better": g["higher_is_better"] if g else None, | |
| 143 | + "n_models": g["model_count"] if g else 0}) | |
| 144 | + if not g: | |
| 145 | + continue | |
| 146 | + leader_cfg = None | |
| 147 | + ranked = rank_rows(g["rows"], g["higher_is_better"]) | |
| 148 | + if ranked: | |
| 149 | + leader_cfg = ranked[0] | |
| 150 | + for r in ranked: | |
| 151 | + if wanted_m is not None and r["model_id"] not in wanted_m: | |
| 152 | + continue | |
| 153 | + level, _ = comparability(leader_cfg["config"] if leader_cfg else None, r.get("config"), leader_cfg["metric"] if leader_cfg else None, r.get("metric")) if leader_cfg else ("comparable", []) | |
| 154 | + if comparable_only and level != "comparable": | |
| 155 | + continue | |
| 156 | + cells[r["model_id"]][bid] = {"score": r["score"], "rank": r["rank"], "trust_level": r["trust_level"], "config_key": r["config_key"], "comparability": level, | |
| 157 | + "result_id": r["id"]} | |
| 158 | + model_ref.setdefault(r["model_id"], {"id": r["model_id"], "slug": r["model_slug"], "name": r["model_name"], "organization": r.get("org_name"), "organization_slug": r.get("org_slug"), | |
| 159 | + "openness": (r.get("model_attrs") or {}).get("openness"), "release_date": (r.get("model_attrs") or {}).get("release_date")}) | |
| 160 | + rows_out = [] | |
| 161 | + for mid, c in cells.items(): | |
| 162 | + if wanted_m is None and len(c) < min_cells: | |
| 163 | + continue | |
| 164 | + rows_out.append({"model": model_ref[mid], "cells": {bid: c.get(bid) for bid in wanted_b}, "n_cells": len(c), | |
| 165 | + "mean_rank": round(sum(x["rank"] for x in c.values()) / len(c), 2)}) | |
| 166 | + rows_out.sort(key=lambda x: (-x["n_cells"], x["mean_rank"], x["model"]["name"] or "")) | |
| 167 | + return {"columns": columns, "rows": rows_out[:limit], "total_rows": len(rows_out), "comparable_only": bool(comparable_only), "min_cells": min_cells if wanted_m is None else None, | |
| 168 | + "methodology": GROUPING_NOTE + " Each cell is the model's best current row in the benchmark's primary group; `mean_rank` is only a sort key, not a composite score."} | |
| 169 | + | |
| 170 | + | |
| 171 | +# ------------------------------------------------------------------------------------------------------------------ one benchmark | |
| 51 | 172 | |
| 52 | 173 | |
| 53 | 174 | @router.get("/{slug}") |
| 54 | 175 | @cached(300) |
| 55 | 176 | async def get_benchmark(request: Request, slug: str) -> dict[str, Any]: |
| 56 | − return await detail_for_type(slug, ("benchmark",)) | |
| 177 | + async with connection() as conn: | |
| 178 | + bench = await _resolve_benchmark(conn, slug) | |
| 179 | + rows = await load_results(conn, benchmark_ids=[bench["id"]]) | |
| 180 | + meta = (await benchmark_meta(conn, [bench["id"]])).get(bench["id"], {}) | |
| 181 | + detail = await detail_for_type(bench["slug"], ("benchmark",)) | |
| 182 | + groups = list(group_rows(rows).values()) | |
| 183 | + pg = primary_group(bench.get("attributes"), groups) | |
| 184 | + detail.update({"family": meta.get("family"), "variant": meta.get("variant"), "metric": meta.get("metric"), "direction": meta.get("direction"), "category": meta.get("category"), | |
| 185 | + "groups": sorted((group_summary(g) for g in groups), key=lambda g: (-g["model_count"], g["label"])), "primary_group": group_summary(pg) if pg else None, | |
| 186 | + "result_count": len(rows), "model_count": len({r["model_id"] for r in rows}), "leaderboard": leaderboard_rows(pg)[:25] if pg else [], | |
| 187 | + "trust_mix": dict(defaultdict(int, {k: sum(1 for r in rows if r["trust_level"] == k) for k in {r["trust_level"] for r in rows}}))}) | |
| 188 | + return detail | |
| 57 | 189 | |
| 58 | 190 | |
| 59 | 191 | @router.get("/{slug}/results") |
| 60 | 192 | @cached(300) |
| 61 | −async def benchmark_results(request: Request, slug: str, p: Pagination = PAGINATION, config: str | None = Query(None, max_length=200), history: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 193 | +async def benchmark_results(request: Request, slug: str, p: Pagination = PAGINATION, config: str | None = Query(None, max_length=200), history: int = Query(0, ge=0, le=1), | |
| 194 | + metric: str | None = Query(None, max_length=80), config_key: str | None = Query(None, max_length=40)) -> dict[str, Any]: | |
| 195 | + """v1 behaviour (one row per result, sorted by score) + `metric=` and `config_key=` filters.""" | |
| 62 | 196 | async with connection() as conn: |
| 63 | − bench = await resolve_entity(conn, slug, ("benchmark",)) | |
| 64 | − items = await leaderboard(conn, bench["id"], limit=p.limit, offset=p.offset, config=config, history=bool(history)) | |
| 65 | − where = "r.benchmark_id = :id" + ("" if history else " and r.valid_to is null") + (" and r.config::text ilike :cfg" if config else "") | |
| 66 | − total = await fetch_val(conn, f"select count(*) from benchmark_results r where {where}", id=bench["id"], cfg=f"%{config}%" if config else None) | |
| 197 | + bench = await _resolve_benchmark(conn, slug) | |
| 198 | + items = await legacy_leaderboard(conn, bench["id"], limit=p.limit, offset=p.offset, config=config, history=bool(history), metric=metric, config_key=config_key) | |
| 199 | + where = "r.benchmark_id = :id" + ("" if history else " and r.valid_to is null") + (" and r.config::text ilike :cfg" if config else "") \ | |
| 200 | + + (" and lower(r.metric) = lower(:metric)" if metric else "") + (" and r.config_key = :ck" if config_key else "") | |
| 201 | + total = await fetch_val(conn, f"select count(*) from benchmark_results r where {where}", id=bench["id"], cfg=f"%{config}%" if config else None, metric=metric, ck=config_key) | |
| 67 | 202 | out = page(items, int(total or 0), p) |
| 68 | 203 | out["benchmark"] = entity_summary(bench) |
| 69 | 204 | return out |
| 70 | 205 | |
| 71 | 206 | |
| 207 | +@router.get("/{slug}/leaderboard") | |
| 208 | +@cached(300) | |
| 209 | +async def benchmark_leaderboard(request: Request, slug: str, metric: str | None = None, config_key: str | None = None, trust: str | None = None, org: str | None = None, | |
| 210 | + since: str | None = None, until: str | None = None, comparable_only: int = Query(0, ge=0, le=1), limit: int = Query(100, ge=1, le=1000), | |
| 211 | + offset: int = Query(0, ge=0)) -> dict[str, Any]: | |
| 212 | + """ONE row per canonical model — best current row inside the chosen comparability group (default: primary group).""" | |
| 213 | + since_ts, until_ts = parse_ts(since, "since"), parse_ts(until, "until") | |
| 214 | + async with connection() as conn: | |
| 215 | + bench = await _resolve_benchmark(conn, slug) | |
| 216 | + rows = await load_results(conn, benchmark_ids=[bench["id"]]) | |
| 217 | + closed = await load_results(conn, benchmark_ids=[bench["id"]], current_only=False) | |
| 218 | + org_id = await resolve_id(conn, org) if org else None | |
| 219 | + groups = list(group_rows(rows).values()) | |
| 220 | + g = _pick_group(groups, bench.get("attributes"), metric=metric, config_key=config_key) | |
| 221 | + if not g: | |
| 222 | + return {"benchmark": entity_summary(bench), "group": None, "groups": [group_summary(x) for x in groups], "items": [], "total": 0, | |
| 223 | + "note": "no current results in the requested group" if (metric or config_key) else "no current results for this benchmark"} | |
| 224 | + trust_set = set(csv(trust)) if trust else None | |
| 225 | + sel = [r for r in g["rows"] if (trust_set is None or r["trust_level"] in trust_set) and (org_id is None or r.get("organization_id") == org_id) | |
| 226 | + and (since_ts is None or (r.get("evaluated_at") or r["observed_at"]) >= since_ts) and (until_ts is None or (r.get("evaluated_at") or r["observed_at"]) <= until_ts)] | |
| 227 | + history_rows = [r for r in closed if r.get("valid_to") is not None and r["metric_canonical"] == g["metric"] and r["config_key"] == g["config_key"]] | |
| 228 | + sub = {**g, "rows": sel} | |
| 229 | + items = leaderboard_rows(sub, history_rows=history_rows or None, comparable_only=bool(comparable_only)) | |
| 230 | + return {"benchmark": entity_summary(bench), "group": group_summary(g), "groups": sorted((group_summary(x) for x in groups), key=lambda x: (-x["model_count"], x["label"])), | |
| 231 | + "items": items[offset:offset + limit], "total": len(items), "limit": limit, "offset": offset, "comparable_only": bool(comparable_only), | |
| 232 | + "filters": {k: v for k, v in {"metric": metric, "config_key": config_key, "trust": trust, "org": org, "since": since, "until": until}.items() if v}, | |
| 233 | + "history_available": bool(history_rows), "methodology": GROUPING_NOTE + " delta_rank compares with the ranking built from the closed (previous) rows of the same group."} | |
| 234 | + | |
| 235 | + | |
| 236 | +@router.get("/{slug}/frontier") | |
| 237 | +@cached(300) | |
| 238 | +async def benchmark_frontier(request: Request, slug: str, metric: str | None = None, config_key: str | None = None) -> dict[str, Any]: | |
| 239 | + """History of the leader per comparability group: a point each time a new best score appears (ordered by coalesce(evaluated_at, observed_at)).""" | |
| 240 | + async with connection() as conn: | |
| 241 | + bench = await _resolve_benchmark(conn, slug) | |
| 242 | + rows = await load_results(conn, benchmark_ids=[bench["id"]], current_only=False) | |
| 243 | + groups = list(group_rows(rows).values()) | |
| 244 | + if metric or config_key: | |
| 245 | + groups = [g for g in groups if (not metric or g["metric"] == metric.strip().lower()) and (not config_key or g["config_key"] == config_key)] | |
| 246 | + pg = primary_group(bench.get("attributes"), groups) | |
| 247 | + series = [] | |
| 248 | + for g in sorted(groups, key=lambda x: (-x["model_count"], x["label"])): | |
| 249 | + pts = frontier_series(g["rows"], g["higher_is_better"]) | |
| 250 | + series.append({"group": group_summary(g), "primary": pg is not None and g["config_key"] == pg["config_key"] and g["metric"] == pg["metric"], "points": pts, | |
| 251 | + "current_leader": pts[-1] if pts else None}) | |
| 252 | + return {"benchmark": entity_summary(bench), "series": series, "generated_at": datetime.now(UTC), | |
| 253 | + "methodology": "Includes closed rows (history). A point is emitted whenever a result beats every earlier result of the same group, " | |
| 254 | + "ordered by evaluated_at when the source publishes it, else observed_at."} | |
| 255 | + | |
| 256 | + | |
| 72 | 257 | @router.get("/{slug}/history") |
| 73 | 258 | @cached(300) |
| 74 | 259 | async def benchmark_history(request: Request, slug: str, model: str | None = None, limit: int = Query(1000, ge=1, le=5000)) -> dict[str, Any]: |
| 75 | 260 | async with connection() as conn: |
| 76 | − bench = await resolve_entity(conn, slug, ("benchmark",)) | |
| 261 | + bench = await _resolve_benchmark(conn, slug) | |
| 77 | 262 | where = ["r.benchmark_id = :id"] |
| 78 | 263 | params: dict[str, Any] = {"id": bench["id"], "lim": limit} |
| 79 | 264 | if model: |
| 80 | 265 | where.append("r.model_id = :model") |
| 81 | 266 | params["model"] = await resolve_id(conn, model) |
| 82 | − rows = await fetch_all(conn, f"select {RESULT_COLS} from {RESULT_FROM} where {' and '.join(where)} order by r.observed_at asc, r.id limit :lim", **params) | |
| 83 | − return {"benchmark": entity_summary(bench), "items": [result_row(r) for r in rows]} | |
| 267 | + rows = await fetch_all(conn, f"select {RESULT_COLS}, r.config_key, r.trust_level from {RESULT_FROM} where {' and '.join(where)} order by r.observed_at asc, r.id limit :lim", **params) | |
| 268 | + items = [] | |
| 269 | + for r in rows: | |
| 270 | + it = result_row(r) | |
| 271 | + it["config_key"], it["trust_level"] = r.get("config_key"), r.get("trust_level") | |
| 272 | + items.append(it) | |
| 273 | + return {"benchmark": entity_summary(bench), "items": items} | |
| 274 | + | |
| 275 | + | |
| 276 | +__all__ = ["ApiError", "router"] | |
modified
src/aiatlas/api/routers/changes.py
+99 −24
@@ -1,4 +1,8 @@ | ||
| 1 | −"""/changes (cursor feed) · /changes/daily ("What changed in AI today") · /changes/categories.""" | |
| 1 | +"""/changes (cursor feed) · /changes/daily ("Today in AI 2.0") · /changes/categories. | |
| 2 | + | |
| 3 | +API 1.1: feeds default to `is_backfill = false` and are keyed on `occurred_at` (= coalesce(effective_at, observed_at)); `include_backfill=1` | |
| 4 | +restores the historical corpus, `date_field=observed` restores the v1 ordering. `/changes/daily` groups the events that share a `group_key` | |
| 5 | +(one release across several documents) into one item with `sources: n` and `documents: [urls]`.""" | |
| 2 | 6 | from __future__ import annotations |
| 3 | 7 | |
| 4 | 8 | from datetime import UTC, datetime |
@@ -11,6 +15,7 @@ from aiatlas.api.common import ( | ||
| 11 | 15 | ENTITY_FROM, |
| 12 | 16 | EVENT_COLS, |
| 13 | 17 | EVENT_FROM, |
| 18 | + OPEN_CATEGORIES, | |
| 14 | 19 | ApiError, |
| 15 | 20 | cached, |
| 16 | 21 | change_event, |
@@ -32,11 +37,28 @@ CATEGORY_LABELS = {"model": "Models", "price": "Pricing", "benchmark": "Benchmar | ||
| 32 | 37 | CATEGORY_ORDER = list(CATEGORY_LABELS) |
| 33 | 38 | TOTAL_CAP = 10_000 |
| 34 | 39 | |
| 40 | +# "Today in AI 2.0" sections — (key, label, SQL predicate on ev/e) | |
| 41 | +SECTIONS: list[tuple[str, str, str]] = [ | |
| 42 | + ("MAJOR_RELEASES", "Major releases", "ev.event_type in ('NEW_MODEL','RELEASE') and ev.importance >= 2 and e.entity_type = 'model'"), | |
| 43 | + ("OPEN_WEIGHT_RELEASES", "Open-weight releases", "ev.event_type = 'NEW_MODEL' and e.entity_type = 'model' and e.attributes->>'openness' in (" + ", ".join(f"'{c}'" for c in OPEN_CATEGORIES) + ")"), | |
| 44 | + ("PRICE_MOVES", "Price moves", "ev.category = 'price' or ev.event_type = 'PRICE_CHANGED'"), | |
| 45 | + ("BENCHMARK_MOVES", "Benchmark moves", "ev.event_type in ('BENCHMARK_UPDATED','BENCHMARK_LEADER_CHANGED','NEW_BENCHMARK_LEADER')"), | |
| 46 | + ("MODEL_CHANGES", "Model changes", "ev.event_type in ('CONTEXT_CHANGED','MAX_OUTPUT_CHANGED','STATUS_CHANGED','CAPABILITIES_CHANGED','PARAMETERS_CHANGED','KNOWLEDGE_CUTOFF_CHANGED','LICENSE_CHANGED','OPENNESS_CHANGED')"), | |
| 47 | + ("RESEARCH", "Research", "ev.event_type = 'NEW_PAPER'"), | |
| 48 | + ("DEPRECATIONS", "Deprecations & retirements", "ev.event_type in ('DEPRECATION_ANNOUNCED','RETIREMENT_ANNOUNCED') or (ev.event_type = 'STATUS_CHANGED' and ev.new_value::text ~* 'deprecated|retired')"), | |
| 49 | + ("PROVIDER_CHANGES", "Provider listings", "ev.event_type in ('PROVIDER_LISTED','PROVIDER_DELISTED','NEW_PROVIDER')"), | |
| 50 | + ("HARDWARE", "Hardware", "ev.category = 'hardware' or ev.event_type = 'NEW_HARDWARE'"), | |
| 51 | +] | |
| 52 | + | |
| 35 | 53 | |
| 36 | 54 | def _filters(*, category: str | None, types: list[str], entity_type: str | None, importance_min: int | None, since: datetime | None, until: datetime | None, |
| 37 | − q: str | None, include_documents: bool, entity_id: str | None = None, before: datetime | None = None) -> tuple[list[str], dict[str, Any]]: | |
| 55 | + q: str | None, include_documents: bool, entity_id: str | None = None, before: datetime | None = None, include_backfill: bool = False, | |
| 56 | + date_field: str = "occurred") -> tuple[list[str], dict[str, Any]]: | |
| 57 | + col = "ev.observed_at" if date_field == "observed" else "ev.occurred_at" | |
| 38 | 58 | where: list[str] = [] |
| 39 | 59 | p: dict[str, Any] = {} |
| 60 | + if not include_backfill: | |
| 61 | + where.append("ev.is_backfill = false") | |
| 40 | 62 | if category: |
| 41 | 63 | where.append("ev.category = any(cast(:cats as text[]))") |
| 42 | 64 | p["cats"] = csv(category) |
@@ -52,13 +74,13 @@ def _filters(*, category: str | None, types: list[str], entity_type: str | None, | ||
| 52 | 74 | where.append("ev.importance >= :imp") |
| 53 | 75 | p["imp"] = importance_min |
| 54 | 76 | if since is not None: |
| 55 | − where.append("ev.observed_at >= :since") | |
| 77 | + where.append(f"{col} >= :since") | |
| 56 | 78 | p["since"] = since |
| 57 | 79 | if until is not None: |
| 58 | − where.append("ev.observed_at <= :until") | |
| 80 | + where.append(f"{col} <= :until") | |
| 59 | 81 | p["until"] = until |
| 60 | 82 | if before is not None: |
| 61 | − where.append("ev.observed_at < :before") | |
| 83 | + where.append(f"{col} < :before") | |
| 62 | 84 | p["before"] = before |
| 63 | 85 | if q: |
| 64 | 86 | where.append("(ev.summary ilike :qlike or e.canonical_name ilike :qlike)") |
@@ -69,59 +91,112 @@ def _filters(*, category: str | None, types: list[str], entity_type: str | None, | ||
| 69 | 91 | return where or ["true"], p |
| 70 | 92 | |
| 71 | 93 | |
| 94 | +def _event(r: dict[str, Any]) -> dict[str, Any]: | |
| 95 | + ev = change_event(r) | |
| 96 | + ev["occurred_at"] = r.get("occurred_at") | |
| 97 | + ev["is_backfill"] = r.get("is_backfill") | |
| 98 | + ev["group_key"] = r.get("group_key") | |
| 99 | + return ev | |
| 100 | + | |
| 101 | + | |
| 72 | 102 | @router.get("") |
| 73 | 103 | @cached(60) |
| 74 | 104 | async def list_changes(request: Request, category: str | None = None, type: str | None = Query(None, alias="type"), entity_type: str | None = None, |
| 75 | 105 | importance_min: int | None = Query(None, ge=0, le=3), since: str | None = None, until: str | None = None, q: str | None = Query(None, max_length=200), |
| 76 | 106 | entity: str | None = None, limit: int = Query(50, ge=1, le=200), before: str | None = None, offset: int = Query(0, ge=0, le=10000), |
| 77 | − include_documents: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 107 | + include_documents: int = Query(0, ge=0, le=1), include_backfill: int = Query(0, ge=0, le=1), | |
| 108 | + date_field: str = Query("occurred", pattern="^(occurred|observed)$")) -> dict[str, Any]: | |
| 78 | 109 | before_ts, since_ts, until_ts = parse_ts(before, "before"), parse_ts(since, "since"), parse_ts(until, "until") |
| 110 | + col = "ev.observed_at" if date_field == "observed" else "ev.occurred_at" | |
| 79 | 111 | async with connection() as conn: |
| 80 | 112 | eid = await resolve_id(conn, entity) if entity else None |
| 81 | 113 | where, params = _filters(category=category, types=csv(type), entity_type=entity_type, importance_min=importance_min, since=since_ts, until=until_ts, q=q, |
| 82 | − include_documents=bool(include_documents), entity_id=eid, before=before_ts) | |
| 114 | + include_documents=bool(include_documents), entity_id=eid, before=before_ts, include_backfill=bool(include_backfill), date_field=date_field) | |
| 83 | 115 | where_sql = " and ".join(where) |
| 84 | − rows = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where {where_sql} order by ev.observed_at desc, ev.id desc limit :lim offset :off", | |
| 116 | + rows = await fetch_all(conn, f"select {EVENT_COLS}, ev.occurred_at, ev.is_backfill, ev.group_key from {EVENT_FROM} where {where_sql} order by {col} desc, ev.id desc limit :lim offset :off", | |
| 85 | 117 | lim=limit, off=offset, **params) |
| 86 | 118 | total = await fetch_val(conn, f"select count(*) from (select 1 from {EVENT_FROM} where {where_sql} limit {TOTAL_CAP}) t", **params) |
| 87 | − items = [change_event(r) for r in rows] | |
| 88 | − return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset, "next_before": items[-1]["observed_at"] if len(items) == limit else None} | |
| 119 | + items = [_event(r) for r in rows] | |
| 120 | + cursor_key = "observed_at" if date_field == "observed" else "occurred_at" | |
| 121 | + return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset, "next_before": items[-1][cursor_key] if len(items) == limit else None, | |
| 122 | + "date_field": date_field, "include_backfill": bool(include_backfill)} | |
| 123 | + | |
| 124 | + | |
| 125 | +def _group(items: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| 126 | + """Fold events that share a `group_key` (one release across documents) into one item: the most important event + sources/documents.""" | |
| 127 | + out: list[dict[str, Any]] = [] | |
| 128 | + by_key: dict[str, dict[str, Any]] = {} | |
| 129 | + for ev in items: | |
| 130 | + k = ev.get("group_key") | |
| 131 | + if not k: | |
| 132 | + out.append({**ev, "sources": 1, "documents": [ev["source_url"]] if ev.get("source_url") else [], "grouped_events": 1}) | |
| 133 | + continue | |
| 134 | + g = by_key.get(k) | |
| 135 | + if g is None: | |
| 136 | + g = by_key[k] = {**ev, "sources": 0, "documents": [], "grouped_events": 0, "event_ids": []} | |
| 137 | + out.append(g) | |
| 138 | + g["grouped_events"] += 1 | |
| 139 | + g["event_ids"].append(ev["id"]) | |
| 140 | + if ev.get("source_url") and ev["source_url"] not in g["documents"]: | |
| 141 | + g["documents"].append(ev["source_url"]) | |
| 142 | + if (ev.get("importance") or 0) > (g.get("importance") or 0): | |
| 143 | + for f in ("id", "event_type", "summary", "importance", "new_value", "old_value", "property", "entity"): | |
| 144 | + g[f] = ev.get(f) | |
| 145 | + for g in out: | |
| 146 | + g["sources"] = max(1, len(g["documents"])) | |
| 147 | + return out | |
| 89 | 148 | |
| 90 | 149 | |
| 91 | 150 | @router.get("/daily") |
| 92 | 151 | @cached(120) |
| 93 | −async def changes_daily(request: Request, date: str | None = None, per_section: int = Query(30, ge=1, le=100)) -> dict[str, Any]: | |
| 152 | +async def changes_daily(request: Request, date: str | None = None, per_section: int = Query(30, ge=1, le=100), include_backfill: int = Query(0, ge=0, le=1), | |
| 153 | + date_field: str = Query("occurred", pattern="^(occurred|observed)$")) -> dict[str, Any]: | |
| 94 | 154 | d = parse_date(date, "date") or datetime.now(UTC).date() |
| 95 | 155 | start, end = day_bounds(d) |
| 156 | + col = "ev.observed_at" if date_field == "observed" else "ev.occurred_at" | |
| 157 | + bf = "" if include_backfill else " and ev.is_backfill = false" | |
| 158 | + base = f"{col} >= :s and {col} <= :e and ev.event_type <> 'DOCUMENT_CHANGED'{bf}" | |
| 96 | 159 | async with connection() as conn: |
| 97 | − rows = await fetch_all(conn, f"""select {EVENT_COLS} from ( | |
| 98 | − select ev.*, row_number() over (partition by ev.category order by ev.importance desc, ev.observed_at desc) as rn | |
| 99 | − from change_events ev where ev.observed_at >= :s and ev.observed_at <= :e and ev.event_type <> 'DOCUMENT_CHANGED') ev | |
| 160 | + rows = await fetch_all(conn, f"""select {EVENT_COLS}, ev.occurred_at, ev.is_backfill, ev.group_key from ( | |
| 161 | + select ev.*, row_number() over (partition by ev.category order by ev.importance desc, ev.occurred_at desc) as rn | |
| 162 | + from change_events ev where {base}) ev | |
| 100 | 163 | left join entities e on e.id = ev.entity_id left join entities eo on eo.id = e.organization_id |
| 101 | 164 | where ev.rn <= :n order by ev.category, ev.rn""", s=start, e=end, n=per_section) |
| 102 | − counts = await fetch_all(conn, "select category, count(*) as n from change_events where observed_at >= :s and observed_at <= :e and event_type <> 'DOCUMENT_CHANGED' group by 1", s=start, e=end) | |
| 165 | + counts = await fetch_all(conn, f"select ev.category, count(*) as n from change_events ev where {base} group by 1", s=start, e=end) | |
| 166 | + backfill_excluded = 0 if include_backfill else int(await fetch_val(conn, f"select count(*) from change_events ev where {col} >= :s and {col} <= :e and ev.is_backfill and ev.event_type <> 'DOCUMENT_CHANGED'", s=start, e=end) or 0) | |
| 103 | 167 | new_models = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and e.first_seen_at >= :s and e.first_seen_at <= :e " |
| 104 | 168 | f"order by e.first_seen_at desc limit 100", s=start, e=end) |
| 105 | − prev = await fetch_val(conn, "select max(observed_at)::date from change_events where observed_at < :s and event_type <> 'DOCUMENT_CHANGED'", s=start) | |
| 106 | − nxt = await fetch_val(conn, "select min(observed_at)::date from change_events where observed_at > :e and event_type <> 'DOCUMENT_CHANGED'", e=end) | |
| 169 | + prev = await fetch_val(conn, f"select max(ev.occurred_at)::date from change_events ev where ev.occurred_at < :s and ev.event_type <> 'DOCUMENT_CHANGED'{bf}", s=start) | |
| 170 | + nxt = await fetch_val(conn, f"select min(ev.occurred_at)::date from change_events ev where ev.occurred_at > :e and ev.event_type <> 'DOCUMENT_CHANGED'{bf}", e=end) | |
| 171 | + sections2: list[dict[str, Any]] = [] | |
| 172 | + for key, label, pred in SECTIONS: | |
| 173 | + srows = await fetch_all(conn, f"""select {EVENT_COLS}, ev.occurred_at, ev.is_backfill, ev.group_key from {EVENT_FROM} where {base} and ({pred}) | |
| 174 | + order by ev.importance desc, ev.occurred_at desc, ev.id limit :n""", s=start, e=end, n=per_section * 3) | |
| 175 | + items = _group([_event(r) for r in srows])[:per_section] | |
| 176 | + total_s = await fetch_val(conn, f"select count(*) from {EVENT_FROM} where {base} and ({pred})", s=start, e=end) | |
| 177 | + if items: | |
| 178 | + sections2.append({"key": key, "label": label, "items": items, "total": int(total_s or 0)}) | |
| 107 | 179 | by_cat: dict[str, list[dict[str, Any]]] = {} |
| 108 | 180 | for r in rows: |
| 109 | − by_cat.setdefault(r["category"], []).append(change_event(r)) | |
| 181 | + by_cat.setdefault(r["category"], []).append(_event(r)) | |
| 110 | 182 | order = {c: i for i, c in enumerate(CATEGORY_ORDER)} |
| 111 | 183 | sections = [{"category": c, "label": CATEGORY_LABELS.get(c, c.title()), "items": items} for c, items in sorted(by_cat.items(), key=lambda kv: (order.get(kv[0], 99), kv[0]))] |
| 112 | 184 | return {"date": d.isoformat(), "counts": {r["category"]: int(r["n"]) for r in counts}, "total": sum(int(r["n"]) for r in counts), "sections": sections, |
| 113 | − "new_models": [entity_summary(r) for r in new_models], "labels": CATEGORY_LABELS, | |
| 114 | − "previous_day": prev.isoformat() if prev else None, "next_day": nxt.isoformat() if nxt else None} | |
| 185 | + "today": sections2, "new_models": [entity_summary(r) for r in new_models], "labels": CATEGORY_LABELS, "backfill_excluded": backfill_excluded, | |
| 186 | + "date_field": date_field, "previous_day": prev.isoformat() if prev else None, "next_day": nxt.isoformat() if nxt else None, | |
| 187 | + "note": "Events that occurred on this UTC day (effective date when known, else observation date), excluding back-filled history and source-document " | |
| 188 | + "changes. `today` groups events sharing a group_key (one release seen in several documents)."} | |
| 115 | 189 | |
| 116 | 190 | |
| 117 | 191 | @router.get("/categories") |
| 118 | 192 | @cached(300) |
| 119 | −async def changes_categories(request: Request, days: int = Query(7, ge=1, le=365)) -> dict[str, Any]: | |
| 193 | +async def changes_categories(request: Request, days: int = Query(7, ge=1, le=365), include_backfill: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 194 | + bf = "" if include_backfill else " and is_backfill = false" | |
| 120 | 195 | async with connection() as conn: |
| 121 | − rows = await fetch_all(conn, """select category, event_type, count(*) as count from change_events where observed_at > now() - make_interval(days => :d) | |
| 122 | − and event_type <> 'DOCUMENT_CHANGED' group by 1, 2 order by 3 desc, 1, 2""", d=days) | |
| 196 | + rows = await fetch_all(conn, f"""select category, event_type, count(*) as count from change_events where occurred_at > now() - make_interval(days => :d) | |
| 197 | + and event_type <> 'DOCUMENT_CHANGED'{bf} group by 1, 2 order by 3 desc, 1, 2""", d=days) | |
| 123 | 198 | return {"days": days, "items": [{"category": r["category"], "label": CATEGORY_LABELS.get(r["category"], r["category"].title()), "event_type": r["event_type"], |
| 124 | 199 | "event_label": event_type_label(r["event_type"]), "count": int(r["count"])} for r in rows]} |
| 125 | 200 | |
| 126 | 201 | |
| 127 | −__all__ = ["CATEGORY_LABELS", "ApiError", "router"] | |
| 202 | +__all__ = ["CATEGORY_LABELS", "SECTIONS", "ApiError", "router"] | |
added
src/aiatlas/api/routers/claims.py
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +"""/claims/{id} — claim lifecycle (API 1.1): the claim, its entity, the chain of superseded / superseding / conflicting siblings, source, extractor, | |
| 2 | +run id and evidence pointer (snapshot id + document URL + archived flag; never raw content).""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Any | |
| 6 | + | |
| 7 | +from fastapi import APIRouter, Request | |
| 8 | + | |
| 9 | +from aiatlas.api.common import ( | |
| 10 | + CLAIM_COLS, | |
| 11 | + CLAIM_FROM, | |
| 12 | + ENTITY_COLS, | |
| 13 | + ENTITY_FROM, | |
| 14 | + ApiError, | |
| 15 | + cached, | |
| 16 | + claim_row, | |
| 17 | + entity_summary, | |
| 18 | +) | |
| 19 | +from aiatlas.db import connection, fetch_all, fetch_one | |
| 20 | + | |
| 21 | +router = APIRouter(prefix="/api/v1/claims", tags=["claims"]) | |
| 22 | + | |
| 23 | + | |
| 24 | +@router.get("/{claim_id}") | |
| 25 | +@cached(120) | |
| 26 | +async def claim_detail(request: Request, claim_id: str) -> dict[str, Any]: | |
| 27 | + async with connection() as conn: | |
| 28 | + c = await fetch_one(conn, f"select {CLAIM_COLS}, c.entity_id, c.snapshot_id, c.run_id, c.extractor_version, c.value_raw, c.value_text, c.value_num, c.source_id, " | |
| 29 | + f"s.domain as source_domain, s.tier as source_tier from {CLAIM_FROM} where c.id = :id", id=claim_id) | |
| 30 | + if not c: | |
| 31 | + raise ApiError(404, "claim not found") | |
| 32 | + ent = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.id = :id", id=c["entity_id"]) | |
| 33 | + siblings = await fetch_all(conn, f"select {CLAIM_COLS}, c.snapshot_id, c.run_id from {CLAIM_FROM} where c.entity_id = :e and c.property = :p and c.id <> :id order by c.valid_from asc, c.observed_at asc limit 200", | |
| 34 | + e=c["entity_id"], p=c["property"], id=claim_id) | |
| 35 | + snap = None | |
| 36 | + if c.get("snapshot_id"): | |
| 37 | + snap = await fetch_one(conn, """select s.id, s.observed_at, s.http_status, s.content_type, s.parser_version, s.raw_path is not null as archived, s.text_path is not null as has_text, | |
| 38 | + d.url as document_url, d.title as document_title, d.doc_type from snapshots s join documents d on d.id = s.document_id where s.id = :id""", id=c["snapshot_id"]) | |
| 39 | + vf = c["valid_from"] | |
| 40 | + previous = [claim_row(s) for s in siblings if s["status"] == "superseded" and (s["valid_to"] is None or s["valid_to"] <= vf)] | |
| 41 | + superseding = [claim_row(s) for s in siblings if s["valid_from"] >= vf and s["status"] in ("current", "superseded") and c["status"] != "current"] | |
| 42 | + conflicting = [claim_row(s) for s in siblings if s["status"] == "conflicting"] | |
| 43 | + return {"claim": {**claim_row(c), "value_raw": c.get("value_raw"), "run_id": c.get("run_id"), "extractor_version": c.get("extractor_version")}, | |
| 44 | + "entity": entity_summary(ent) if ent else {"id": c["entity_id"]}, "property": c["property"], | |
| 45 | + "chain": {"previous": previous[-5:], "superseding": superseding[:5], "conflicting": conflicting, "history_count": len(siblings) + 1}, | |
| 46 | + "source": {"id": c.get("source_id"), "name": c.get("source_name"), "domain": c.get("source_domain"), "tier": c.get("tier"), "url": c.get("source_url"), | |
| 47 | + "snapshot_id": c.get("snapshot_id"), "observed_at": c.get("observed_at")}, | |
| 48 | + "extractor": {"name": c.get("extractor"), "version": c.get("extractor_version"), "confidence": c.get("confidence")}, "run_id": c.get("run_id"), | |
| 49 | + "evidence": {"snapshot_id": c.get("snapshot_id"), "document_url": (snap or {}).get("document_url") or c.get("source_url"), "archived": bool((snap or {}).get("archived")), | |
| 50 | + "snapshot_observed_at": (snap or {}).get("observed_at"), "document_title": (snap or {}).get("document_title"), "doc_type": (snap or {}).get("doc_type")}, | |
| 51 | + "note": "Evidence is a pointer to the archived snapshot; raw content is available to administrators only (/admin/extractions/{snapshot_id})."} | |
modified
src/aiatlas/api/routers/compare.py
+74 −26
@@ -1,4 +1,8 @@ | ||
| 1 | −"""/compare?ids=a,b,… — side-by-side of 2–6 entities of the same type with per-type dimension sets and provenance.""" | |
| 1 | +"""/compare?ids=a,b,… — side-by-side of 2–6 entities of the same type with per-type dimension sets and provenance. | |
| 2 | + | |
| 3 | +API 1.1: benchmark dimensions are keyed by (benchmark, canonical metric, config_key) and only appear when EVERY compared model has a current | |
| 4 | +result in that comparability group; `comparability` labels each benchmark dimension; `diff_only=1` keeps only differing dimensions; | |
| 5 | +`mode=models|providers|hardware|companies|frameworks` asserts the expected type.""" | |
| 2 | 6 | from __future__ import annotations |
| 3 | 7 | |
| 4 | 8 | from typing import Any |
@@ -9,8 +13,6 @@ from aiatlas.api.common import ( | ||
| 9 | 13 | COMPANY_TYPES, |
| 10 | 14 | PRICE_COLS, |
| 11 | 15 | PRICE_FROM, |
| 12 | − RESULT_COLS, | |
| 13 | − RESULT_FROM, | |
| 14 | 16 | ApiError, |
| 15 | 17 | cached, |
| 16 | 18 | csv, |
@@ -18,9 +20,10 @@ from aiatlas.api.common import ( | ||
| 18 | 20 | entity_summary, |
| 19 | 21 | price_row, |
| 20 | 22 | resolve_entity, |
| 21 | − result_row, | |
| 22 | 23 | ) |
| 23 | 24 | from aiatlas.db import connection, fetch_all |
| 25 | +from aiatlas.ontology.benchmarks import TRUST_LABELS, comparability | |
| 26 | +from aiatlas.services.frontier import best_per_model, config_summary, group_rows, load_results | |
| 24 | 27 | |
| 25 | 28 | router = APIRouter(prefix="/api/v1/compare", tags=["compare"]) |
| 26 | 29 | |
@@ -30,7 +33,7 @@ DIMENSIONS: dict[str, list[dict[str, Any]]] = { | ||
| 30 | 33 | "model": [D("parameter_count", "Parameters", "number", "params"), D("active_parameter_count", "Active parameters", "number", "params"), |
| 31 | 34 | D("context_length", "Context window", "number", "tokens"), D("max_output_tokens", "Max output", "number", "tokens"), D("openness", "Openness"), |
| 32 | 35 | D("license", "License"), D("modalities", "Modalities", "list"), D("release_date", "Release date", "date"), D("knowledge_cutoff", "Knowledge cutoff", "date"), |
| 33 | − D("status", "Status"), D("family", "Family"), D("architecture", "Architecture"), | |
| 36 | + D("status", "Status"), D("family", "Family"), D("architecture", "Architecture"), D("reasoning", "Reasoning", "bool"), D("tool_calling", "Tool calling", "bool"), | |
| 34 | 37 | D("best_input_per_mtok", "Best input price", "number", "USD / 1M tokens", "prices"), D("best_output_per_mtok", "Best output price", "number", "USD / 1M tokens", "prices"), |
| 35 | 38 | D("provider_count", "Providers", "number", None, "prices")], |
| 36 | 39 | "provider": [D("website", "Website"), D("pricing_url", "Pricing page"), D("model_count", "Models priced", "number", None, "prices"), |
@@ -44,50 +47,77 @@ DIMENSIONS: dict[str, list[dict[str, Any]]] = { | ||
| 44 | 47 | D("model_count", "Models", "number", None, "graph"), D("paper_count", "Papers", "number", None, "graph")], |
| 45 | 48 | "benchmark": [D("category", "Category"), D("metric", "Metric"), D("unit", "Unit"), D("task", "Task"), D("result_count", "Results", "number", None, "results")], |
| 46 | 49 | } |
| 50 | +MODES = {"models": "model", "providers": "provider", "hardware": "hardware", "companies": "company", "frameworks": "framework", "benchmarks": "benchmark"} | |
| 51 | +TYPE_ALIASES = {"library": "framework", "runtime": "framework", "artifact": "model"} | |
| 47 | 52 | |
| 48 | 53 | |
| 49 | −@router.get("") | |
| 50 | −@cached(300) | |
| 51 | −async def compare(request: Request, ids: str = Query(..., description="2–6 slugs or ids, comma-separated")) -> dict[str, Any]: | |
| 52 | − keys = csv(ids) | |
| 54 | +def _norm(v: Any) -> Any: | |
| 55 | + if isinstance(v, list): | |
| 56 | + return sorted(str(x) for x in v) | |
| 57 | + if isinstance(v, float) and v.is_integer(): | |
| 58 | + return int(v) | |
| 59 | + return v | |
| 60 | + | |
| 61 | + | |
| 62 | +async def compare_entities(keys: list[str], *, diff_only: bool = False, mode: str | None = None) -> dict[str, Any]: | |
| 53 | 63 | if not 2 <= len(keys) <= 6: |
| 54 | 64 | raise ApiError(400, "ids must list between 2 and 6 entities") |
| 65 | + if mode and mode not in MODES: | |
| 66 | + raise ApiError(400, f"mode must be one of {', '.join(MODES)}") | |
| 55 | 67 | async with connection() as conn: |
| 56 | 68 | rows = [await resolve_entity(conn, k) for k in keys] |
| 57 | − types = {r["entity_type"] for r in rows} | |
| 58 | − etype = rows[0]["entity_type"] | |
| 69 | + types = {TYPE_ALIASES.get(r["entity_type"], r["entity_type"]) for r in rows} | |
| 70 | + etype = TYPE_ALIASES.get(rows[0]["entity_type"], rows[0]["entity_type"]) | |
| 59 | 71 | if etype in COMPANY_TYPES: |
| 60 | 72 | etype = "company" |
| 61 | 73 | types = {"company"} |
| 62 | 74 | if len(types) != 1: |
| 63 | 75 | raise ApiError(400, f"all entities must share one type, got {', '.join(sorted(types))}") |
| 76 | + if mode and MODES[mode] != etype: | |
| 77 | + raise ApiError(400, f"mode={mode} expects {MODES[mode]} entities, got {etype}") | |
| 64 | 78 | dims = [dict(d) for d in DIMENSIONS.get(etype, [])] |
| 65 | 79 | eids = [r["id"] for r in rows] |
| 66 | 80 | items: list[dict[str, Any]] = [] |
| 67 | 81 | extra: dict[str, dict[str, Any]] = {eid: {} for eid in eids} |
| 68 | 82 | prices_by: dict[str, list[dict[str, Any]]] = {eid: [] for eid in eids} |
| 69 | 83 | results_by: dict[str, list[dict[str, Any]]] = {eid: [] for eid in eids} |
| 84 | + comp: dict[str, dict[str, Any]] = {} | |
| 70 | 85 | if etype == "model": |
| 71 | 86 | for p in await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.model_id = any(cast(:ids as text[])) and p.valid_to is null order by p.input_per_mtok nulls last", ids=eids): |
| 72 | 87 | prices_by[p["m_id"]].append(price_row(p)) |
| 73 | 88 | for eid, plist in prices_by.items(): |
| 74 | − ins = [x["input_per_mtok"] for x in plist if x["input_per_mtok"] is not None] | |
| 75 | − outs = [x["output_per_mtok"] for x in plist if x["output_per_mtok"] is not None] | |
| 89 | + ins = [x["input_per_mtok"] for x in plist if x["input_per_mtok"] is not None and x["input_per_mtok"] > 0] | |
| 90 | + outs = [x["output_per_mtok"] for x in plist if x["output_per_mtok"] is not None and x["output_per_mtok"] > 0] | |
| 76 | 91 | extra[eid] = {"best_input_per_mtok": min(ins) if ins else None, "best_output_per_mtok": min(outs) if outs else None, |
| 77 | 92 | "provider_count": len({x["provider"]["id"] for x in plist if x["provider"]})} |
| 78 | − res = await fetch_all(conn, f"select {RESULT_COLS} from {RESULT_FROM} where r.model_id = any(cast(:ids as text[])) and r.valid_to is null order by b.canonical_name, r.observed_at desc", ids=eids) | |
| 79 | − per_bench: dict[str, dict[str, float]] = {} | |
| 80 | − bench_meta: dict[str, dict[str, Any]] = {} | |
| 93 | + res = await load_results(conn, model_ids=eids, canonical_models_only=False) | |
| 81 | 94 | for r in res: |
| 82 | − results_by[r["m_id"]].append(result_row(r)) | |
| 83 | − per_bench.setdefault(r["b_id"], {}).setdefault(r["m_id"], r["score"]) | |
| 84 | − bench_meta.setdefault(r["b_id"], {"slug": r["b_slug"], "name": r["b_canonical_name"], "unit": r.get("unit") or r.get("b_attributes", {}).get("unit"), "higher_is_better": r["higher_is_better"]}) | |
| 85 | − for bid, scores in per_bench.items(): | |
| 86 | − if all(eid in scores for eid in eids): | |
| 87 | − meta = bench_meta[bid] | |
| 88 | − dims.append({"key": f"bench:{meta['slug']}", "label": meta["name"], "kind": "number", "unit": meta["unit"], "source": "results", "higher_is_better": meta["higher_is_better"]}) | |
| 89 | − for eid in eids: | |
| 90 | − extra[eid][f"bench:{meta['slug']}"] = scores[eid] | |
| 95 | + results_by[r["model_id"]].append({"id": r["id"], "benchmark": {"id": r["benchmark_id"], "slug": r["benchmark_slug"], "name": r["benchmark_name"]}, "score": r["score"], | |
| 96 | + "metric": r["metric_canonical"], "unit": r.get("unit"), "higher_is_better": r.get("higher_is_better"), "config": config_summary(r.get("config")), | |
| 97 | + "config_key": r["config_key"], "trust_level": r["trust_level"], "evaluated_at": r.get("evaluated_at"), "observed_at": r["observed_at"], | |
| 98 | + "source_url": r.get("source_url"), "tier": r.get("tier")}) | |
| 99 | + for g in sorted(group_rows(res).values(), key=lambda g: (g["rows"][0]["benchmark_name"] or "", g["label"])): | |
| 100 | + if g["models"] < set(eids) and not set(eids) <= g["models"]: | |
| 101 | + continue | |
| 102 | + best = {r["model_id"]: r for r in best_per_model(g["rows"], g["higher_is_better"])} | |
| 103 | + if not all(eid in best for eid in eids): | |
| 104 | + continue | |
| 105 | + first = g["rows"][0] | |
| 106 | + key = f"bench:{first['benchmark_slug']}:{g['metric']}:{g['config_key']}" | |
| 107 | + ref = best[eids[0]] | |
| 108 | + worst_level, reasons = "comparable", [] | |
| 109 | + for eid in eids[1:]: | |
| 110 | + level, why = comparability(ref.get("config"), best[eid].get("config"), ref.get("metric"), best[eid].get("metric")) | |
| 111 | + if level == "not-comparable" or (level == "partially-comparable" and worst_level == "comparable"): | |
| 112 | + worst_level = level | |
| 113 | + reasons += [w for w in why if w not in reasons and level != "comparable"] | |
| 114 | + dims.append({"key": key, "label": f"{first['benchmark_name']} · {g['label']}", "kind": "number", "unit": first.get("unit"), "source": "results", | |
| 115 | + "higher_is_better": g["higher_is_better"], "benchmark": first["benchmark_slug"], "metric": g["metric"], "config_key": g["config_key"], | |
| 116 | + "comparability": worst_level, "trust_levels": sorted({best[e]["trust_level"] for e in eids})}) | |
| 117 | + comp[key] = {"level": worst_level, "reasons": reasons or ["same variant, metric and evaluation conditions"], | |
| 118 | + "trust": {e: {"level": best[e]["trust_level"], "label": TRUST_LABELS.get(best[e]["trust_level"], best[e]["trust_level"])} for e in eids}} | |
| 119 | + for eid in eids: | |
| 120 | + extra[eid][key] = best[eid]["score"] | |
| 91 | 121 | elif etype == "provider": |
| 92 | 122 | agg = await fetch_all(conn, """select provider_id, count(distinct model_id) as model_count, min(nullif(input_per_mtok, 0)) as min_input_per_mtok, |
| 93 | 123 | min(nullif(output_per_mtok, 0)) as min_output_per_mtok, jsonb_agg(distinct k.key) filter (where k.key is not null) as features |
@@ -115,4 +145,22 @@ async def compare(request: Request, ids: str = Query(..., description="2–6 slu | ||
| 115 | 145 | item["prices"] = prices_by[r["id"]] |
| 116 | 146 | item["results"] = results_by[r["id"]] |
| 117 | 147 | items.append(item) |
| 118 | − return {"entity_type": etype, "dimensions": dims, "items": items} | |
| 148 | + if diff_only: | |
| 149 | + keep = [d for d in dims if len({repr(_norm(it["values"].get(d["key"]))) for it in items}) > 1] | |
| 150 | + kept = {d["key"] for d in keep} | |
| 151 | + dims = keep | |
| 152 | + for it in items: | |
| 153 | + it["values"] = {k: v for k, v in it["values"].items() if k in kept} | |
| 154 | + it["provenance"] = {k: v for k, v in it["provenance"].items() if k in kept} | |
| 155 | + return {"entity_type": etype, "dimensions": dims, "items": items, "comparability": comp, "diff_only": diff_only, | |
| 156 | + "note": "Benchmark dimensions appear only when every compared model has a current result in the same comparability group (benchmark × metric × config_key)."} | |
| 157 | + | |
| 158 | + | |
| 159 | +@router.get("") | |
| 160 | +@cached(300) | |
| 161 | +async def compare(request: Request, ids: str = Query(..., description="2–6 slugs or ids, comma-separated"), diff_only: int = Query(0, ge=0, le=1), | |
| 162 | + mode: str | None = Query(None, description="models|providers|hardware|companies|frameworks|benchmarks")) -> dict[str, Any]: | |
| 163 | + return await compare_entities(csv(ids), diff_only=bool(diff_only), mode=mode) | |
| 164 | + | |
| 165 | + | |
| 166 | +__all__ = ["DIMENSIONS", "compare_entities", "router"] | |
added
src/aiatlas/api/routers/deployments.py
+111 −0
@@ -0,0 +1,111 @@ | ||
| 1 | +"""/deployments (cursor page of `Deployment` rows) · /cost (per request / day / month / year for every current deployment) · | |
| 2 | +/cost/context (cost of one fully populated context).""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Any | |
| 6 | + | |
| 7 | +from fastapi import APIRouter, Query, Request | |
| 8 | + | |
| 9 | +from aiatlas.api.common import ( | |
| 10 | + PRICE_COLS, | |
| 11 | + PRICE_FROM, | |
| 12 | + ApiError, | |
| 13 | + cached, | |
| 14 | + deployment_row, | |
| 15 | + parse_ts, | |
| 16 | + resolve_entity, | |
| 17 | + resolve_id, | |
| 18 | +) | |
| 19 | +from aiatlas.db import connection, fetch_all, fetch_val | |
| 20 | +from aiatlas.services.cost import compute_cost, context_fill_cost | |
| 21 | + | |
| 22 | +router = APIRouter(prefix="/api/v1", tags=["deployments"]) | |
| 23 | +TOTAL_CAP = 10_000 | |
| 24 | + | |
| 25 | + | |
| 26 | +@router.get("/deployments") | |
| 27 | +@cached(300) | |
| 28 | +async def list_deployments(request: Request, model: str | None = None, provider: str | None = None, current: int = Query(1, ge=0, le=1), limit: int = Query(50, ge=1, le=200), | |
| 29 | + offset: int = Query(0, ge=0, le=10000), before: str | None = Query(None, description="cursor: valid_from of the last item"), | |
| 30 | + org: str | None = None, sort: str = Query("valid_from", pattern="^(valid_from|output|input|model|provider)$")) -> dict[str, Any]: | |
| 31 | + where = ["m.entity_type in ('model','artifact')", "m.merged_into is null"] | |
| 32 | + params: dict[str, Any] = {"lim": limit, "off": offset} | |
| 33 | + if current: | |
| 34 | + where.append("p.valid_to is null") | |
| 35 | + before_ts = parse_ts(before, "before") | |
| 36 | + if before_ts is not None: | |
| 37 | + where.append("p.valid_from < :before") | |
| 38 | + params["before"] = before_ts | |
| 39 | + async with connection() as conn: | |
| 40 | + if model: | |
| 41 | + where.append("p.model_id = :model") | |
| 42 | + params["model"] = await resolve_id(conn, model) | |
| 43 | + if provider: | |
| 44 | + where.append("p.provider_id = :provider") | |
| 45 | + params["provider"] = await resolve_id(conn, provider) | |
| 46 | + if org: | |
| 47 | + where.append("(mo.slug = :org or mo.id = :org or mo.canonical_name ilike :org)") | |
| 48 | + params["org"] = org | |
| 49 | + order = {"valid_from": "p.valid_from desc, p.id desc", "output": "p.output_per_mtok asc nulls last, p.id", "input": "p.input_per_mtok asc nulls last, p.id", | |
| 50 | + "model": "m.canonical_name asc, pv.canonical_name asc, p.id", "provider": "pv.canonical_name asc, m.canonical_name asc, p.id"}[sort] | |
| 51 | + where_sql = " and ".join(where) | |
| 52 | + rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where {where_sql} order by {order} limit :lim offset :off", **params) | |
| 53 | + total = await fetch_val(conn, f"select count(*) from (select 1 from {PRICE_FROM} where {where_sql} limit {TOTAL_CAP}) t", **{k: v for k, v in params.items() if k not in ('lim', 'off')}) | |
| 54 | + items = [deployment_row(r) for r in rows] | |
| 55 | + return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset, | |
| 56 | + "next_before": items[-1]["valid_from"] if len(items) == limit and sort == "valid_from" else None, "current": bool(current)} | |
| 57 | + | |
| 58 | + | |
| 59 | +@router.get("/cost") | |
| 60 | +@cached(300) | |
| 61 | +async def cost(request: Request, model: str = Query(..., description="model slug or id"), provider: str | None = None, input_tokens: int = Query(1000, ge=0, le=100_000_000), | |
| 62 | + output_tokens: int = Query(500, ge=0, le=100_000_000), requests_per_day: float = Query(1000, ge=0, le=1e9), cached_share: float = Query(0.0, ge=0, le=1), | |
| 63 | + batch: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 64 | + async with connection() as conn: | |
| 65 | + m = await resolve_entity(conn, model, ("model", "artifact")) | |
| 66 | + where = ["p.model_id = :mid", "p.valid_to is null"] | |
| 67 | + params: dict[str, Any] = {"mid": m["id"]} | |
| 68 | + if provider: | |
| 69 | + where.append("p.provider_id = :pid") | |
| 70 | + params["pid"] = await resolve_id(conn, provider, ("provider",)) | |
| 71 | + rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where {' and '.join(where)} order by p.output_per_mtok asc nulls last, pv.canonical_name limit 200", **params) | |
| 72 | + items = [] | |
| 73 | + for r in rows: | |
| 74 | + d = deployment_row(r) | |
| 75 | + c = compute_cost(d["prices"], input_tokens=input_tokens, output_tokens=output_tokens, requests_per_day=requests_per_day, cached_share=cached_share, batch=bool(batch)) | |
| 76 | + items.append({"deployment": d, "cost": c}) | |
| 77 | + items.sort(key=lambda x: (x["cost"]["per_request"] is None, x["cost"]["per_request"] or 0)) | |
| 78 | + return {"model": {"id": m["id"], "slug": m["slug"], "name": m["canonical_name"]}, "inputs": {"input_tokens": input_tokens, "output_tokens": output_tokens, "requests_per_day": requests_per_day, | |
| 79 | + "cached_share": cached_share, "batch": bool(batch)}, | |
| 80 | + "items": items, "total": len(items), "currency": "USD", | |
| 81 | + "methodology": "per_request = input_tokens × effective input price / 1e6 + output_tokens × output price / 1e6 (+ per-request fee when published); " | |
| 82 | + "effective input = (1 − cached_share) × input + cached_share × cached input (falls back to the standard price with a note); " | |
| 83 | + "batch uses batch prices when published; daily = per_request × requests_per_day; monthly = daily × 30; annual = daily × 365. Only current offers.", | |
| 84 | + "note": None if items else "no current deployment for this model"} | |
| 85 | + | |
| 86 | + | |
| 87 | +@router.get("/cost/context") | |
| 88 | +@cached(300) | |
| 89 | +async def cost_context(request: Request, tokens: int = Query(1_000_000, ge=1, le=100_000_000), limit: int = Query(50, ge=1, le=500), model: str | None = None, | |
| 90 | + org: str | None = None) -> dict[str, Any]: | |
| 91 | + """Cost of ONE fully populated context of `tokens` input tokens per current deployment whose context window is ≥ tokens, cheapest first.""" | |
| 92 | + where = ["p.valid_to is null", "p.input_per_mtok > 0", "m.entity_type in ('model','artifact')", "m.merged_into is null", | |
| 93 | + "coalesce(p.context_length, case when m.attributes->>'context_length' ~ '^[0-9]+$' then (m.attributes->>'context_length')::bigint end) >= :tokens"] | |
| 94 | + params: dict[str, Any] = {"tokens": tokens, "lim": limit} | |
| 95 | + async with connection() as conn: | |
| 96 | + if model: | |
| 97 | + where.append("p.model_id = :mid") | |
| 98 | + params["mid"] = await resolve_id(conn, model) | |
| 99 | + if org: | |
| 100 | + where.append("(mo.slug = :org or mo.id = :org or mo.canonical_name ilike :org)") | |
| 101 | + params["org"] = org | |
| 102 | + rows = await fetch_all(conn, f"""select {PRICE_COLS}, coalesce(p.context_length, case when m.attributes->>'context_length' ~ '^[0-9]+$' then (m.attributes->>'context_length')::bigint end) as ctx | |
| 103 | + from {PRICE_FROM} where {' and '.join(where)} order by p.input_per_mtok asc, m.canonical_name limit :lim""", **params) | |
| 104 | + items = [{"deployment": deployment_row(r), "context_length": r["ctx"], "context_source": "offer" if r.get("context_length") else "model attribute", | |
| 105 | + "cost_usd": context_fill_cost(r["input_per_mtok"], tokens)} for r in rows] | |
| 106 | + return {"tokens": tokens, "items": items, "total": len(items), "currency": "USD", | |
| 107 | + "methodology": "cost = input price (USD per 1M tokens) × tokens / 1e6 for every current offer whose context window (offer's, else the model's attribute) is at least `tokens`. " | |
| 108 | + "Long-context surcharges published as native units are not applied."} | |
| 109 | + | |
| 110 | + | |
| 111 | +__all__ = ["ApiError", "router"] | |
modified
src/aiatlas/api/routers/diff.py
+55 −16
@@ -1,4 +1,6 @@ | ||
| 1 | −"""/diff?a=&b=&scope= — what changed between two dates: new / gone entities, property, price and benchmark changes.""" | |
| 1 | +"""/diff?a=&b=&scope= — what changed between two dates: new / gone entities, property, price and benchmark changes, new benchmark leaders, | |
| 2 | +provider listings, hardware, context changes, retired models. API 1.1: events use `occurred_at` and `is_backfill = false`; `new_entities` | |
| 3 | +excludes artifacts unless `include=artifacts`.""" | |
| 2 | 4 | from __future__ import annotations |
| 3 | 5 | |
| 4 | 6 | from datetime import UTC, datetime |
@@ -19,9 +21,12 @@ from aiatlas.api.common import ( | ||
| 19 | 21 | parse_date, |
| 20 | 22 | ) |
| 21 | 23 | from aiatlas.db import connection, fetch_all, fetch_one, fetch_val |
| 24 | +from aiatlas.services.frontier import benchmark_meta, leader_at, load_results | |
| 22 | 25 | |
| 23 | 26 | router = APIRouter(prefix="/api/v1/diff", tags=["diff"]) |
| 24 | 27 | GONE_TYPES = ("RETIREMENT_ANNOUNCED", "DEPRECATION_ANNOUNCED", "STATUS_CHANGED", "ENTITY_MERGED", "MODEL_RETIRED", "MODEL_DEPRECATED") |
| 28 | +RETIRED_PRED = "(ev.event_type in ('RETIREMENT_ANNOUNCED','DEPRECATION_ANNOUNCED','MODEL_RETIRED','MODEL_DEPRECATED') or (ev.event_type = 'STATUS_CHANGED' and ev.new_value::text ~* 'retired|deprecated'))" | |
| 29 | +_leaders_at = leader_at | |
| 25 | 30 | |
| 26 | 31 | |
| 27 | 32 | async def _scope(conn: Any, scope: str) -> tuple[str, str, dict[str, Any], dict[str, Any]]: |
@@ -39,7 +44,7 @@ async def _scope(conn: Any, scope: str) -> tuple[str, str, dict[str, Any], dict[ | ||
| 39 | 44 | frag = "(e.organization_id = :org or e.id = :org)" |
| 40 | 45 | return frag, frag, {"org": org["id"]}, {"kind": "org", "organization": {"id": org["id"], "slug": org["slug"], "name": org["canonical_name"]}} |
| 41 | 46 | if s.startswith("family:"): |
| 42 | − frag = "e.attributes->>'family' ilike :family" | |
| 47 | + frag = "(e.attributes->>'family' ilike :family or exists (select 1 from entities f where f.id = e.family_id and (f.slug = :family or f.canonical_name ilike :family)))" | |
| 43 | 48 | return frag, frag, {"family": s[7:]}, {"kind": "family", "family": s[7:]} |
| 44 | 49 | raise ApiError(400, "scope must be all | models | org:<slug> | family:<name>") |
| 45 | 50 | |
@@ -47,39 +52,73 @@ async def _scope(conn: Any, scope: str) -> tuple[str, str, dict[str, Any], dict[ | ||
| 47 | 52 | @router.get("") |
| 48 | 53 | @cached(300) |
| 49 | 54 | async def diff(request: Request, a: str = Query(..., description="YYYY-MM-DD"), b: str = Query(..., description="YYYY-MM-DD"), scope: str = "all", |
| 50 | − limit: int = Query(200, ge=1, le=1000)) -> dict[str, Any]: | |
| 55 | + limit: int = Query(200, ge=1, le=1000), include: str | None = None, include_backfill: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 51 | 56 | da, db = parse_date(a, "a"), parse_date(b, "b") |
| 52 | 57 | assert da is not None and db is not None |
| 53 | 58 | if da > db: |
| 54 | 59 | da, db = db, da |
| 55 | 60 | start, end = datetime.combine(da, dtime.max, UTC), datetime.combine(db, dtime.max, UTC) |
| 61 | + include_artifacts = "artifacts" in {x.strip() for x in (include or "").split(",")} | |
| 62 | + bf = "" if include_backfill else " and ev.is_backfill = false" | |
| 63 | + win = f"ev.occurred_at > :s and ev.occurred_at <= :e{bf}" | |
| 56 | 64 | async with connection() as conn: |
| 57 | 65 | ent_frag, ev_frag, params, scope_desc = await _scope(conn, scope) |
| 58 | − new_entities = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.merged_into is null and {ent_frag} and e.first_seen_at > :s and e.first_seen_at <= :e " | |
| 66 | + art = "" if include_artifacts else " and e.entity_type <> 'artifact'" | |
| 67 | + new_entities = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.merged_into is null and {ent_frag}{art} and e.first_seen_at > :s and e.first_seen_at <= :e " | |
| 59 | 68 | f"order by e.first_seen_at desc limit :lim", s=start, e=end, lim=limit, **params) |
| 60 | 69 | gone = await fetch_all(conn, f"""select distinct on (e.id) {ENTITY_COLS} from change_events ev join entities e on e.id = ev.entity_id left join entities eo on eo.id = e.organization_id |
| 61 | − where {ev_frag} and ev.observed_at > :s and ev.observed_at <= :e and (ev.event_type = any(cast(:gt as text[])) or e.merged_into is not null) | |
| 70 | + where {ev_frag} and {win} and (ev.event_type = any(cast(:gt as text[])) or e.merged_into is not null) | |
| 62 | 71 | and (e.status in ('retired','deprecated','merged') or ev.event_type in ('RETIREMENT_ANNOUNCED','DEPRECATION_ANNOUNCED')) |
| 63 | 72 | order by e.id limit :lim""", s=start, e=end, gt=list(GONE_TYPES), lim=limit, **params) |
| 64 | 73 | |
| 65 | 74 | async def events(cond: str) -> list[dict[str, Any]]: |
| 66 | − rows = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where {ev_frag} and ev.observed_at > :s and ev.observed_at <= :e and {cond} " | |
| 67 | − f"order by ev.importance desc, ev.observed_at desc limit :lim", s=start, e=end, lim=limit, **params) | |
| 68 | − return [change_event(r) for r in rows] | |
| 75 | + rows = await fetch_all(conn, f"select {EVENT_COLS}, ev.occurred_at, ev.is_backfill from {EVENT_FROM} where {ev_frag} and {win} and {cond} " | |
| 76 | + f"order by ev.importance desc, ev.occurred_at desc limit :lim", s=start, e=end, lim=limit, **params) | |
| 77 | + out = [] | |
| 78 | + for r in rows: | |
| 79 | + ev = change_event(r) | |
| 80 | + ev["occurred_at"] = r.get("occurred_at") | |
| 81 | + out.append(ev) | |
| 82 | + return out | |
| 69 | 83 | |
| 70 | 84 | prop_changes = await events("ev.property is not null and ev.category not in ('price','benchmark') and ev.event_type <> 'DOCUMENT_CHANGED'") |
| 71 | 85 | price_changes = await events("ev.category = 'price'") |
| 72 | 86 | bench_changes = await events("ev.category = 'benchmark'") |
| 73 | − counts = await fetch_one(conn, f"""select (select count(*) from entities e where e.merged_into is null and {ent_frag} and e.first_seen_at > :s and e.first_seen_at <= :e) as new_entities, | |
| 74 | − (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and ev.observed_at > :s and ev.observed_at <= :e and ev.event_type <> 'DOCUMENT_CHANGED') as events, | |
| 75 | − (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and ev.observed_at > :s and ev.observed_at <= :e and ev.category = 'price') as price_changes, | |
| 76 | − (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and ev.observed_at > :s and ev.observed_at <= :e and ev.category = 'benchmark') as benchmark_changes, | |
| 87 | + provider_changes = await events("ev.event_type in ('PROVIDER_LISTED','PROVIDER_DELISTED','NEW_PROVIDER')") | |
| 88 | + hardware_changes = await events("(ev.category = 'hardware' or ev.event_type = 'NEW_HARDWARE')") | |
| 89 | + context_changes = await events("ev.event_type in ('CONTEXT_CHANGED','MAX_OUTPUT_CHANGED')") | |
| 90 | + retired = await events(RETIRED_PRED) | |
| 91 | + counts = await fetch_one(conn, f"""select (select count(*) from entities e where e.merged_into is null and {ent_frag}{art} and e.first_seen_at > :s and e.first_seen_at <= :e) as new_entities, | |
| 92 | + (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and {win} and ev.event_type <> 'DOCUMENT_CHANGED') as events, | |
| 93 | + (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and {win} and ev.category = 'price') as price_changes, | |
| 94 | + (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and {win} and ev.category = 'benchmark') as benchmark_changes, | |
| 95 | + (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and {win} and ev.event_type in ('PROVIDER_LISTED','PROVIDER_DELISTED','NEW_PROVIDER')) as provider_changes, | |
| 96 | + (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and {win} and ev.event_type in ('CONTEXT_CHANGED','MAX_OUTPUT_CHANGED')) as context_changes, | |
| 97 | + (select count(*) from change_events ev left join entities e on e.id = ev.entity_id where {ev_frag} and {win} and {RETIRED_PRED}) as retired_models, | |
| 77 | 98 | (select count(*) from prices p join entities e on e.id = p.model_id where {ent_frag} and p.valid_from > :s and p.valid_from <= :e) as price_rows_opened, |
| 78 | 99 | (select count(*) from prices p join entities e on e.id = p.model_id where {ent_frag} and p.valid_to > :s and p.valid_to <= :e) as price_rows_closed, |
| 79 | 100 | (select count(*) from claims c join entities e on e.id = c.entity_id where {ent_frag} and c.valid_to > :s and c.valid_to <= :e) as claims_superseded""", |
| 80 | 101 | s=start, e=end, **params) |
| 81 | − entities_at_a = await fetch_val(conn, f"select count(*) from entities e where e.merged_into is null and {ent_frag} and e.first_seen_at <= :s", s=start, **params) | |
| 82 | − entities_at_b = await fetch_val(conn, f"select count(*) from entities e where e.merged_into is null and {ent_frag} and e.first_seen_at <= :e", e=end, **params) | |
| 102 | + entities_at_a = await fetch_val(conn, f"select count(*) from entities e where e.merged_into is null and {ent_frag}{art} and e.first_seen_at <= :s", s=start, **params) | |
| 103 | + entities_at_b = await fetch_val(conn, f"select count(*) from entities e where e.merged_into is null and {ent_frag}{art} and e.first_seen_at <= :e", e=end, **params) | |
| 104 | + # new benchmark leaders: leader of each benchmark's primary group at a vs at b (from results observed by each date) | |
| 105 | + meta = await benchmark_meta(conn) | |
| 106 | + all_rows = await load_results(conn, current_only=False) | |
| 107 | + by_bench: dict[str, list[dict[str, Any]]] = {} | |
| 108 | + for r in all_rows: | |
| 109 | + by_bench.setdefault(r["benchmark_id"], []).append(r) | |
| 110 | + new_leaders = [] | |
| 111 | + for bid, rows in by_bench.items(): | |
| 112 | + m = meta.get(bid, {}) | |
| 113 | + la, lb = _leaders_at(rows, start, m.get("attributes")), _leaders_at(rows, end, m.get("attributes")) | |
| 114 | + if lb and (la is None or la["model"]["id"] != lb["model"]["id"]): | |
| 115 | + new_leaders.append({"benchmark": {"id": bid, "slug": m.get("slug"), "name": m.get("name"), "category": m.get("category")}, "at_a": la, "at_b": lb}) | |
| 116 | + new_leaders.sort(key=lambda x: x["benchmark"]["name"] or "") | |
| 83 | 117 | return {"a": da.isoformat(), "b": db.isoformat(), "scope": scope_desc, "new_entities": [entity_summary(r) for r in new_entities], "gone_entities": [entity_summary(r) for r in gone], |
| 84 | − "property_changes": prop_changes, "price_changes": price_changes, "benchmark_changes": bench_changes, | |
| 85 | − "counts": {**{k: int(v or 0) for k, v in (counts or {}).items()}, "gone_entities": len(gone), "entities_at_a": int(entities_at_a or 0), "entities_at_b": int(entities_at_b or 0)}} | |
| 118 | + "property_changes": prop_changes, "price_changes": price_changes, "benchmark_changes": bench_changes, "new_benchmark_leaders": new_leaders, | |
| 119 | + "provider_changes": provider_changes, "hardware_changes": hardware_changes, "context_changes": context_changes, "retired_models": retired, | |
| 120 | + "counts": {**{k: int(v or 0) for k, v in (counts or {}).items()}, "gone_entities": len(gone), "new_benchmark_leaders": len(new_leaders), | |
| 121 | + "entities_at_a": int(entities_at_a or 0), "entities_at_b": int(entities_at_b or 0)}, | |
| 122 | + "include_artifacts": include_artifacts, "include_backfill": bool(include_backfill), | |
| 123 | + "note": "Events are keyed on occurred_at (effective date when known) and exclude back-filled history unless include_backfill=1; new_entities uses first_seen_at. " | |
| 124 | + "new_benchmark_leaders compares the primary-group leader computed from results observed by each date."} | |
modified
src/aiatlas/api/routers/entities.py
+66 −6
@@ -1,4 +1,4 @@ | ||
| 1 | −"""/entities/{slug_or_id} and its sub-resources (timeline, history, asof, graph, sources, related).""" | |
| 1 | +"""/entities/{slug_or_id} and its sub-resources (timeline, history, asof, graph, sources, related, claims, provenance).""" | |
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | |
| 4 | 4 | from datetime import UTC, datetime |
@@ -21,7 +21,7 @@ from aiatlas.api.common import ( | ||
| 21 | 21 | resolve_entity, |
| 22 | 22 | ) |
| 23 | 23 | from aiatlas.api.detail import entity_detail, sources_of, timeline_of |
| 24 | −from aiatlas.db import connection, fetch_all, fetch_one | |
| 24 | +from aiatlas.db import connection, fetch_all, fetch_one, fetch_val | |
| 25 | 25 | |
| 26 | 26 | router = APIRouter(prefix="/api/v1/entities", tags=["entities"]) |
| 27 | 27 | |
@@ -37,12 +37,15 @@ async def get_entity(request: Request, slug_or_id: str) -> dict[str, Any]: | ||
| 37 | 37 | @router.get("/{slug_or_id}/timeline") |
| 38 | 38 | @cached(60) |
| 39 | 39 | async def entity_timeline(request: Request, slug_or_id: str, limit: int = Query(50, ge=1, le=200), before: str | None = None, |
| 40 | − include_documents: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 40 | + include_documents: int = Query(0, ge=0, le=1), include_backfill: int = Query(0, ge=0, le=1), | |
| 41 | + date_field: str = Query("occurred", pattern="^(occurred|observed)$")) -> dict[str, Any]: | |
| 41 | 42 | before_ts = parse_ts(before, "before") |
| 42 | 43 | async with connection() as conn: |
| 43 | 44 | row = await resolve_entity(conn, slug_or_id) |
| 44 | − items = await timeline_of(conn, row["id"], row["entity_type"], limit=limit, before=before_ts, include_documents=bool(include_documents)) | |
| 45 | − return {"items": items, "next_before": items[-1]["observed_at"] if len(items) == limit else None} | |
| 45 | + items = await timeline_of(conn, row["id"], row["entity_type"], limit=limit, before=before_ts, include_documents=bool(include_documents), | |
| 46 | + include_backfill=bool(include_backfill), date_field=date_field) | |
| 47 | + cursor_key = "observed_at" if date_field == "observed" else "occurred_at" | |
| 48 | + return {"items": items, "next_before": items[-1][cursor_key] if len(items) == limit else None, "date_field": date_field, "include_backfill": bool(include_backfill)} | |
| 46 | 49 | |
| 47 | 50 | |
| 48 | 51 | @router.get("/{slug_or_id}/history") |
@@ -56,6 +59,61 @@ async def entity_history(request: Request, slug_or_id: str, property: str | None | ||
| 56 | 59 | return {"items": [claim_row(r) for r in rows]} |
| 57 | 60 | |
| 58 | 61 | |
| 62 | +@router.get("/{slug_or_id}/claims") | |
| 63 | +@cached(120) | |
| 64 | +async def entity_claims(request: Request, slug_or_id: str, property: str | None = Query(None, max_length=120), status: str | None = Query(None, max_length=40), | |
| 65 | + limit: int = Query(200, ge=1, le=2000), offset: int = Query(0, ge=0)) -> dict[str, Any]: | |
| 66 | + """Public claim list (current by default; `status=all|superseded|conflicting|retracted`), newest first, with claim ids for `/claims/{id}`.""" | |
| 67 | + async with connection() as conn: | |
| 68 | + row = await resolve_entity(conn, slug_or_id) | |
| 69 | + where = ["c.entity_id = :id"] | |
| 70 | + params: dict[str, Any] = {"id": row["id"], "lim": limit, "off": offset} | |
| 71 | + if property: | |
| 72 | + where.append("c.property = :p") | |
| 73 | + params["p"] = property | |
| 74 | + st = status or "current" | |
| 75 | + if st != "all": | |
| 76 | + where.append("c.status = :st") | |
| 77 | + params["st"] = st | |
| 78 | + rows = await fetch_all(conn, f"select {CLAIM_COLS}, c.entity_id, c.snapshot_id, c.run_id, c.extractor_version, c.value_raw from {CLAIM_FROM} where {' and '.join(where)} " | |
| 79 | + f"order by c.property, c.valid_from desc limit :lim offset :off", **params) | |
| 80 | + total = await fetch_val(conn, f"select count(*) from claims c where {' and '.join(where)}", **{k: v for k, v in params.items() if k not in ('lim', 'off')}) | |
| 81 | + items = [{**claim_row(r), "snapshot_id": r.get("snapshot_id"), "run_id": r.get("run_id"), "value_raw": r.get("value_raw")} for r in rows] | |
| 82 | + return {"entity": entity_summary(row), "items": items, "total": int(total or 0), "limit": limit, "offset": offset, "status": st} | |
| 83 | + | |
| 84 | + | |
| 85 | +@router.get("/{slug_or_id}/provenance/{property}") | |
| 86 | +@cached(120) | |
| 87 | +async def entity_provenance(request: Request, slug_or_id: str, property: str) -> dict[str, Any]: | |
| 88 | + """Evidence-drawer payload for one property: current value, source, tier, extractor, confidence, conflicts, history count, snapshot id (no raw content).""" | |
| 89 | + async with connection() as conn: | |
| 90 | + row = await resolve_entity(conn, slug_or_id) | |
| 91 | + cur = await fetch_one(conn, f"select {CLAIM_COLS}, c.snapshot_id, c.run_id, c.extractor_version, c.value_raw, s.tier as source_tier, s.domain as source_domain, " | |
| 92 | + f"s.id as source_id from {CLAIM_FROM} where c.entity_id = :id and c.property = :p and c.status = 'current' order by c.tier, c.valid_from desc limit 1", | |
| 93 | + id=row["id"], p=property) | |
| 94 | + conflicts = await fetch_all(conn, f"select {CLAIM_COLS}, c.snapshot_id from {CLAIM_FROM} where c.entity_id = :id and c.property = :p and c.status = 'conflicting' " | |
| 95 | + f"order by c.tier, c.observed_at desc limit 20", id=row["id"], p=property) | |
| 96 | + history = await fetch_val(conn, "select count(*) from claims c where c.entity_id = :id and c.property = :p", id=row["id"], p=property) | |
| 97 | + snap = None | |
| 98 | + if cur and cur.get("snapshot_id"): | |
| 99 | + snap = await fetch_one(conn, "select s.id, s.observed_at, d.url as document_url, d.title, s.raw_path is not null as archived from snapshots s join documents d on d.id = s.document_id where s.id = :id", | |
| 100 | + id=cur["snapshot_id"]) | |
| 101 | + attrs = row.get("attributes") or {} | |
| 102 | + prov = (row.get("provenance") or {}).get(property) or {} | |
| 103 | + if not cur and property not in attrs: | |
| 104 | + raise ApiError(404, f"no claim for property {property!r}") | |
| 105 | + return {"entity": {"id": row["id"], "slug": row["slug"], "name": row["canonical_name"], "entity_type": row["entity_type"]}, "property": property, | |
| 106 | + "value": cur["value"] if cur else attrs.get(property), "value_raw": cur.get("value_raw") if cur else None, "unit": (cur or {}).get("unit") or prov.get("unit"), | |
| 107 | + "source": {"id": (cur or {}).get("source_id") or prov.get("source_id"), "name": (cur or {}).get("source_name") or prov.get("source_name"), | |
| 108 | + "domain": (cur or {}).get("source_domain"), "url": (cur or {}).get("source_url") or prov.get("url")}, | |
| 109 | + "tier": (cur or {}).get("tier") or prov.get("tier"), "confidence": (cur or {}).get("confidence") or prov.get("confidence"), | |
| 110 | + "extractor": (cur or {}).get("extractor") or prov.get("extractor"), "extractor_version": (cur or {}).get("extractor_version"), | |
| 111 | + "observed_at": (cur or {}).get("observed_at") or prov.get("observed_at"), "effective_at": (cur or {}).get("effective_at"), | |
| 112 | + "valid_since": (cur or {}).get("valid_from"), "claim_id": (cur or {}).get("id"), "run_id": (cur or {}).get("run_id"), | |
| 113 | + "snapshot_id": (cur or {}).get("snapshot_id"), "snapshot": snap, "conflicts": [claim_row(c) for c in conflicts], "history_count": int(history or 0), | |
| 114 | + "note": None if cur else "value materialised in attributes without a current claim row (curated or inherited)"} | |
| 115 | + | |
| 116 | + | |
| 59 | 117 | @router.get("/{slug_or_id}/asof") |
| 60 | 118 | @cached(300) |
| 61 | 119 | async def entity_asof(request: Request, slug_or_id: str, date: str = Query(..., description="YYYY-MM-DD")) -> dict[str, Any]: |
@@ -126,6 +184,8 @@ async def entity_related(request: Request, slug_or_id: str, limit: int = Query(1 | ||
| 126 | 184 | union all |
| 127 | 185 | select e.id, 4 from entities e where :fam <> '' and e.entity_type = :t and e.attributes->>'family' = :fam and e.id <> :id |
| 128 | 186 | union all |
| 187 | + select e.id, 5 from entities e where :fid <> '' and e.entity_type = :t and e.family_id = :fid and e.id <> :id | |
| 188 | + union all | |
| 129 | 189 | select r2.subject_id, 2 from relations r1 join relations r2 on r2.object_id = r1.object_id and r2.predicate = r1.predicate |
| 130 | 190 | where r1.subject_id = :id and r1.valid_to is null and r2.valid_to is null and r2.subject_id <> :id |
| 131 | 191 | union all |
@@ -134,7 +194,7 @@ async def entity_related(request: Request, slug_or_id: str, limit: int = Query(1 | ||
| 134 | 194 | scored as (select id, sum(w) as w from cand group by id) |
| 135 | 195 | select {ENTITY_COLS} from scored join entities e on e.id = scored.id left join entities eo on eo.id = e.organization_id |
| 136 | 196 | where e.merged_into is null order by scored.w desc, coalesce((e.quality->>'score')::float, 0) desc, e.updated_at desc limit :lim""", |
| 137 | − id=row["id"], t=row["entity_type"], org=row.get("organization_id") or "", fam=str(fam or ""), lim=limit) | |
| 197 | + id=row["id"], t=row["entity_type"], org=row.get("organization_id") or "", fam=str(fam or ""), fid=row.get("family_id") or "", lim=limit) | |
| 138 | 198 | return {"items": [entity_summary(r) for r in rows]} |
| 139 | 199 | |
| 140 | 200 | |
added
src/aiatlas/api/routers/families.py
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +"""/families · /families/{slug} — model families (API 1.1). Canonical `model_family` entities first; pre-canonicalisation data is served | |
| 2 | +from the legacy `attributes.family` label (items flagged `canonical: false`).""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from collections import defaultdict | |
| 6 | +from typing import Any | |
| 7 | + | |
| 8 | +from fastapi import APIRouter, Query, Request | |
| 9 | + | |
| 10 | +from aiatlas.api.common import ENTITY_COLS, ENTITY_FROM, ApiError, cached, entity_summary | |
| 11 | +from aiatlas.api.detail import LINEAGE_PREDICATES | |
| 12 | +from aiatlas.db import connection, fetch_all, fetch_one, fetch_val | |
| 13 | +from aiatlas.ontology.licenses import LICENSES, normalize_license | |
| 14 | +from aiatlas.services.frontier import all_primary_groups, rank_rows | |
| 15 | + | |
| 16 | +router = APIRouter(prefix="/api/v1/families", tags=["families"]) | |
| 17 | +MEMBER_SQL = "((f.id is not null and e.family_id = f.id) or (f.id is null and e.attributes->>'family' = f.label))" | |
| 18 | + | |
| 19 | + | |
| 20 | +def _f(v: Any) -> float | None: | |
| 21 | + if v is None or isinstance(v, bool): | |
| 22 | + return None | |
| 23 | + try: | |
| 24 | + return float(v) | |
| 25 | + except (TypeError, ValueError): | |
| 26 | + return None | |
| 27 | + | |
| 28 | + | |
| 29 | +def _aggregate(members: list[dict[str, Any]], ranks: dict[str, dict[str, int]]) -> dict[str, Any]: | |
| 30 | + params = [p for p in (_f((m["attributes"] or {}).get("parameter_count")) for m in members) if p] | |
| 31 | + dates = sorted(str((m["attributes"] or {}).get("release_date") or "")[:10] for m in members if (m["attributes"] or {}).get("release_date")) | |
| 32 | + mods: set[str] = set() | |
| 33 | + lics: dict[str, int] = defaultdict(int) | |
| 34 | + best: dict[str, tuple[int, str]] = {} | |
| 35 | + for m in members: | |
| 36 | + a = m["attributes"] or {} | |
| 37 | + for k in ("modalities", "modalities_input", "modalities_output"): | |
| 38 | + if isinstance(a.get(k), list): | |
| 39 | + mods |= {str(x).lower() for x in a[k]} | |
| 40 | + key = a.get("license_key") or normalize_license(a.get("license")) | |
| 41 | + if key or a.get("license"): | |
| 42 | + lics[key or str(a.get("license"))] += 1 | |
| 43 | + for b, rk in ranks.get(m["id"], {}).items(): | |
| 44 | + if b not in best or rk < best[b][0]: | |
| 45 | + best[b] = (rk, m["slug"]) | |
| 46 | + return {"model_count": len(members), "first_release": dates[0] if dates else None, "last_release": dates[-1] if dates else None, | |
| 47 | + "param_range": {"min": min(params), "max": max(params)} if params else None, "modalities": sorted(mods), | |
| 48 | + "licenses": [{"key": k, "label": LICENSES[k].label if k in LICENSES else k, "models": n} for k, n in sorted(lics.items(), key=lambda kv: -kv[1])], | |
| 49 | + "benchmark_best": {b: {"rank": rk, "model": slug} for b, (rk, slug) in sorted(best.items())}} | |
| 50 | + | |
| 51 | + | |
| 52 | +async def _families(conn: Any) -> list[dict[str, Any]]: | |
| 53 | + """Canonical model_family entities + legacy labels not yet backed by an entity.""" | |
| 54 | + fam = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model_family' and e.merged_into is null order by e.canonical_name") | |
| 55 | + labels = await fetch_all(conn, """select e.attributes->>'family' as label, min(e.organization_id) as organization_id, count(*) as n from entities e | |
| 56 | + where e.entity_type = 'model' and e.merged_into is null and e.family_id is null and e.attributes ? 'family' group by 1 order by 1""") | |
| 57 | + out = [{"id": r["id"], "slug": r["slug"], "name": r["canonical_name"], "label": r["canonical_name"], "canonical": True, "summary": entity_summary(r), "organization_id": r["organization_id"]} for r in fam] | |
| 58 | + names = {x["name"].lower() for x in out} | |
| 59 | + for r in labels: | |
| 60 | + if r["label"] and r["label"].lower() not in names: | |
| 61 | + out.append({"id": None, "slug": r["label"].lower().replace(" ", "-").replace(".", "-"), "name": r["label"], "label": r["label"], "canonical": False, "summary": None, "organization_id": r["organization_id"]}) | |
| 62 | + return out | |
| 63 | + | |
| 64 | + | |
| 65 | +@router.get("") | |
| 66 | +@cached(300) | |
| 67 | +async def list_families(request: Request, q: str | None = Query(None, max_length=120), org: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), | |
| 68 | + sort: str = Query("models", pattern="^(models|name|last_release)$")) -> dict[str, Any]: | |
| 69 | + async with connection() as conn: | |
| 70 | + fams = await _families(conn) | |
| 71 | + members = await fetch_all(conn, f"""select e.id, e.slug, e.canonical_name, e.attributes, e.family_id, e.attributes->>'family' as label, e.organization_id, eo.slug as org_slug, eo.canonical_name as org_name | |
| 72 | + from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and (e.family_id is not null or e.attributes ? 'family')""") | |
| 73 | + groups = await all_primary_groups(conn) | |
| 74 | + ranks: dict[str, dict[str, int]] = defaultdict(dict) | |
| 75 | + for g in groups.values(): | |
| 76 | + for r in rank_rows(g["rows"], g["higher_is_better"]): | |
| 77 | + ranks[r["model_id"]][g["benchmark"]["slug"]] = r["rank"] | |
| 78 | + by_fid: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 79 | + by_label: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 80 | + for m in members: | |
| 81 | + if m["family_id"]: | |
| 82 | + by_fid[m["family_id"]].append(m) | |
| 83 | + elif m["label"]: | |
| 84 | + by_label[m["label"].lower()].append(m) | |
| 85 | + items = [] | |
| 86 | + for f in fams: | |
| 87 | + mem = by_fid.get(f["id"], []) if f["id"] else by_label.get(f["name"].lower(), []) | |
| 88 | + if not mem and not f["canonical"]: | |
| 89 | + continue | |
| 90 | + if q and q.lower() not in f["name"].lower(): | |
| 91 | + continue | |
| 92 | + orgs = defaultdict(int) | |
| 93 | + for m in mem: | |
| 94 | + if m["organization_id"]: | |
| 95 | + orgs[(m["organization_id"], m["org_slug"], m["org_name"])] += 1 | |
| 96 | + top_org = max(orgs.items(), key=lambda kv: kv[1])[0] if orgs else None | |
| 97 | + if org and not (top_org and (top_org[1] == org or top_org[0] == org or (top_org[2] or "").lower() == org.lower())): | |
| 98 | + continue | |
| 99 | + items.append({"id": f["id"], "slug": f["slug"], "name": f["name"], "canonical": f["canonical"], "entity_type": "model_family", | |
| 100 | + "organization": {"id": top_org[0], "slug": top_org[1], "name": top_org[2]} if top_org else None, **_aggregate(mem, ranks)}) | |
| 101 | + key = {"models": lambda x: (-x["model_count"], x["name"]), "name": lambda x: x["name"].lower(), "last_release": lambda x: (x["last_release"] or "", x["name"])}[sort] | |
| 102 | + items.sort(key=key, reverse=(sort == "last_release")) | |
| 103 | + return {"items": items[offset:offset + limit], "total": len(items), "limit": limit, "offset": offset, | |
| 104 | + "note": "canonical: true = model_family entity; false = legacy attributes.family label awaiting canonicalisation. benchmark_best = best rank of any member in each benchmark's primary group."} | |
| 105 | + | |
| 106 | + | |
| 107 | +@router.get("/{slug}") | |
| 108 | +@cached(300) | |
| 109 | +async def family_detail(request: Request, slug: str, limit: int = Query(100, ge=1, le=500)) -> dict[str, Any]: | |
| 110 | + async with connection() as conn: | |
| 111 | + fam = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model_family' and e.merged_into is null and (e.slug = :s or e.id = :s or e.canonical_name ilike :s) limit 1", s=slug) | |
| 112 | + label = fam["canonical_name"] if fam else slug | |
| 113 | + if fam: | |
| 114 | + members = await fetch_all(conn, f"select {ENTITY_COLS}, e.family_id from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and (e.family_id = :fid or e.attributes->>'family' ilike :label) " | |
| 115 | + f"order by e.attributes->>'release_date' asc nulls last, e.canonical_name limit :lim", fid=fam["id"], label=label, lim=limit) | |
| 116 | + else: | |
| 117 | + members = await fetch_all(conn, f"select {ENTITY_COLS}, e.family_id from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and e.family_id is null " | |
| 118 | + f"and (e.attributes->>'family' ilike :label or replace(replace(lower(e.attributes->>'family'), ' ', '-'), '.', '-') = lower(:slug)) " | |
| 119 | + f"order by e.attributes->>'release_date' asc nulls last, e.canonical_name limit :lim", label=label, slug=slug, lim=limit) | |
| 120 | + if not fam and not members: | |
| 121 | + raise ApiError(404, f"family {slug!r} not found") | |
| 122 | + ids = [m["id"] for m in members] | |
| 123 | + arts = await fetch_val(conn, "select count(*) from entities where entity_type = 'artifact' and merged_into is null and canonical_id = any(cast(:ids as text[]))", ids=ids) if ids else 0 | |
| 124 | + providers = await fetch_all(conn, f"select distinct {ENTITY_COLS} from prices p join entities e on e.id = p.provider_id left join entities eo on eo.id = e.organization_id where p.model_id = any(cast(:ids as text[])) and p.valid_to is null order by e.canonical_name", ids=ids) if ids else [] | |
| 125 | + edges = await fetch_all(conn, "select r.subject_id, r.predicate, r.object_id from relations r where r.valid_to is null and r.predicate = any(cast(:p as text[])) and r.subject_id = any(cast(:ids as text[])) and r.object_id = any(cast(:ids as text[]))", | |
| 126 | + p=[*LINEAGE_PREDICATES, "superseded_by"], ids=ids) if ids else [] | |
| 127 | + events = await fetch_all(conn, """select ev.entity_id, ev.event_type, ev.summary, ev.occurred_at, ev.importance from change_events ev where ev.entity_id = any(cast(:ids as text[])) | |
| 128 | + and ev.event_type in ('NEW_MODEL','RELEASE','DEPRECATION_ANNOUNCED','RETIREMENT_ANNOUNCED','STATUS_CHANGED') order by ev.occurred_at asc limit 500""", ids=ids) if ids else [] | |
| 129 | + groups = await all_primary_groups(conn) | |
| 130 | + ranks: dict[str, dict[str, int]] = defaultdict(dict) | |
| 131 | + for g in groups.values(): | |
| 132 | + for r in rank_rows(g["rows"], g["higher_is_better"]): | |
| 133 | + ranks[r["model_id"]][g["benchmark"]["slug"]] = r["rank"] | |
| 134 | + mem_summ = [] | |
| 135 | + for m in members: | |
| 136 | + a = m["attributes"] or {} | |
| 137 | + mem_summ.append({"model": entity_summary(m), "key_facts": {k: a.get(k) for k in ("release_date", "parameter_count", "active_parameter_count", "context_length", "openness", "license", "modalities", "status") if a.get(k) not in (None, "", [])}, | |
| 138 | + "benchmark_ranks": dict(sorted(ranks.get(m["id"], {}).items()))}) | |
| 139 | + timeline = [{"date": str((m["attributes"] or {}).get("release_date") or "")[:10] or None, "kind": "release", "model": {"id": m["id"], "slug": m["slug"], "name": m["canonical_name"]}} for m in members] | |
| 140 | + timeline += [{"date": e["occurred_at"], "kind": e["event_type"], "summary": e["summary"], "model_id": e["entity_id"]} for e in events if e["event_type"] != "NEW_MODEL"] | |
| 141 | + timeline.sort(key=lambda x: str(x["date"] or "")) | |
| 142 | + return {"id": fam["id"] if fam else None, "slug": fam["slug"] if fam else slug, "name": label, "canonical": bool(fam), "entity_type": "model_family", | |
| 143 | + "summary": entity_summary(fam) if fam else None, **_aggregate(members, ranks), "members": mem_summ, "artifacts_count": int(arts or 0), | |
| 144 | + "providers": [entity_summary(p) for p in providers], "lineage": [{"source": e["subject_id"], "target": e["object_id"], "predicate": e["predicate"]} for e in edges], | |
| 145 | + "timeline": timeline, "note": None if fam else "served from the legacy attributes.family label (no model_family entity yet)"} | |
added
src/aiatlas/api/routers/graph.py
+78 −0
@@ -0,0 +1,78 @@ | ||
| 1 | +"""/graph/explore — neighbourhood exploration filtered by predicate sets per mode (API 1.1). `/entities/{slug}/graph` (v1) is unchanged.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from typing import Any | |
| 5 | + | |
| 6 | +from fastapi import APIRouter, Query, Request | |
| 7 | + | |
| 8 | +from aiatlas.api.common import ApiError, cached, resolve_entity | |
| 9 | +from aiatlas.db import connection, fetch_all | |
| 10 | + | |
| 11 | +router = APIRouter(prefix="/api/v1/graph", tags=["graph"]) | |
| 12 | + | |
| 13 | +MODES: dict[str, tuple[str, ...]] = { | |
| 14 | + "lineage": ("artifact_of", "quantized_from", "fine_tuned_from", "distilled_from", "merged_from", "derived_from", "superseded_by", "member_of_family", "develops"), | |
| 15 | + "research": ("authored", "described_by", "published_by", "uses_dataset", "evaluates_on", "evaluated_on"), | |
| 16 | + "company": ("develops", "owns", "operates", "manufactures", "member_of_family"), | |
| 17 | + "benchmark": ("evaluated_on", "evaluates_on", "variant_of"), | |
| 18 | + "dataset": ("uses_dataset", "derived_from", "subset_of"), | |
| 19 | + "provider": ("available_through",), | |
| 20 | + "hardware": ("runs_on", "uses", "manufactures"), | |
| 21 | +} | |
| 22 | +KEY_ATTRS = ("parameter_count", "context_length", "release_date", "openness", "published_at", "category", "memory_gb", "kind") | |
| 23 | + | |
| 24 | + | |
| 25 | +@router.get("/explore") | |
| 26 | +@cached(300) | |
| 27 | +async def explore(request: Request, node: str = Query(...), mode: str = Query("lineage"), depth: int = Query(1, ge=1, le=2), limit: int = Query(150, ge=2, le=150)) -> dict[str, Any]: | |
| 28 | + if mode not in MODES: | |
| 29 | + raise ApiError(400, f"mode must be one of {', '.join(MODES)}") | |
| 30 | + preds = list(MODES[mode]) | |
| 31 | + async with connection() as conn: | |
| 32 | + root_row = await resolve_entity(conn, node) | |
| 33 | + root = root_row["id"] | |
| 34 | + seen: dict[str, int] = {root: 0} | |
| 35 | + frontier = [root] | |
| 36 | + edges: dict[tuple[str, str, str], dict[str, Any]] = {} | |
| 37 | + truncated = False | |
| 38 | + for level in range(1, depth + 1): | |
| 39 | + if not frontier: | |
| 40 | + break | |
| 41 | + rows = await fetch_all(conn, """select r.subject_id, r.predicate, r.object_id, r.attributes, r.tier from relations r | |
| 42 | + where r.valid_to is null and r.predicate = any(cast(:preds as text[])) | |
| 43 | + and (r.subject_id = any(cast(:ids as text[])) or r.object_id = any(cast(:ids as text[]))) | |
| 44 | + order by r.tier, r.observed_at desc limit :lim""", preds=preds, ids=frontier, lim=limit * 4) | |
| 45 | + nxt: list[str] = [] | |
| 46 | + for r in rows: | |
| 47 | + other = r["object_id"] if r["subject_id"] in seen else r["subject_id"] | |
| 48 | + if other not in seen: | |
| 49 | + if len(seen) >= limit: | |
| 50 | + truncated = True | |
| 51 | + continue | |
| 52 | + seen[other] = level | |
| 53 | + nxt.append(other) | |
| 54 | + edges[(r["subject_id"], r["object_id"], r["predicate"])] = {"source": r["subject_id"], "target": r["object_id"], "predicate": r["predicate"], "attributes": r["attributes"] or {}, "tier": r["tier"]} | |
| 55 | + frontier = nxt | |
| 56 | + # organisation edges for the company mode come from entities.organization_id as well | |
| 57 | + if mode == "company": | |
| 58 | + org_rows = await fetch_all(conn, "select id, organization_id from entities where organization_id is not null and merged_into is null and (id = any(cast(:ids as text[])) or organization_id = any(cast(:ids as text[]))) limit :lim", | |
| 59 | + ids=list(seen), lim=limit * 4) | |
| 60 | + for r in org_rows: | |
| 61 | + for x in (r["id"], r["organization_id"]): | |
| 62 | + if x not in seen: | |
| 63 | + if len(seen) >= limit: | |
| 64 | + truncated = True | |
| 65 | + break | |
| 66 | + seen[x] = depth | |
| 67 | + edges.setdefault((r["organization_id"], r["id"], "develops"), {"source": r["organization_id"], "target": r["id"], "predicate": "develops", "attributes": {"from": "organization_id"}, "tier": None}) | |
| 68 | + nodes = await fetch_all(conn, """select e.id, e.slug, e.canonical_name, e.entity_type, e.attributes, e.merged_into, e.artifact_kind, o.canonical_name as org_name, o.slug as org_slug | |
| 69 | + from entities e left join entities o on o.id = e.organization_id where e.id = any(cast(:ids as text[]))""", ids=list(seen)) | |
| 70 | + node_ids = {n["id"] for n in nodes} | |
| 71 | + out_edges = [e for e in edges.values() if e["source"] in node_ids and e["target"] in node_ids] | |
| 72 | + counts: dict[str, int] = {} | |
| 73 | + for n in nodes: | |
| 74 | + counts[n["entity_type"]] = counts.get(n["entity_type"], 0) + 1 | |
| 75 | + return {"root": root, "mode": mode, "depth": depth, "predicates": preds, | |
| 76 | + "nodes": [{"id": n["id"], "slug": n["slug"], "name": n["canonical_name"], "entity_type": n["entity_type"], "org": n["org_name"], "org_slug": n["org_slug"], "level": seen.get(n["id"], 0), | |
| 77 | + "artifact_kind": n["artifact_kind"], "attributes": {k: (n["attributes"] or {}).get(k) for k in KEY_ATTRS if (n["attributes"] or {}).get(k) not in (None, "", [])}} for n in nodes], | |
| 78 | + "edges": out_edges, "truncated": truncated, "counts": {"nodes": len(nodes), "edges": len(out_edges), "by_type": counts}} | |
modified
src/aiatlas/api/routers/hardware.py
+30 −0
@@ -86,6 +86,36 @@ async def hardware_fit(request: Request, memory_gb: float = Query(..., gt=0, le= | ||
| 86 | 86 | "counts": {"fits": sum(1 for i in items if i["fits"]), "evaluated": len(items)}, "items": items[:limit]} |
| 87 | 87 | |
| 88 | 88 | |
| 89 | +@router.get("/{slug}/fit") | |
| 90 | +@cached(300) | |
| 91 | +async def hardware_entity_fit(request: Request, slug: str, quant: str = Query("4bit"), context: int = Query(8192, ge=0, le=10_000_000), memory_gb: float | None = Query(None, gt=0), | |
| 92 | + gpu_count: int = Query(1, ge=1, le=8), limit: int = Query(100, ge=1, le=500), openness: str | None = None) -> dict[str, Any]: | |
| 93 | + """ESTIMATED fit of canonical models on ONE hardware entity (largest memory configuration unless `memory_gb` picks one).""" | |
| 94 | + from aiatlas.api.common import openness_values, resolve_entity | |
| 95 | + | |
| 96 | + q = hf.normalize_quant(quant) | |
| 97 | + async with connection() as conn: | |
| 98 | + hw = await resolve_entity(conn, slug, ("hardware",)) | |
| 99 | + options = hf.hardware_memory_options(hw.get("attributes")) | |
| 100 | + if not options: | |
| 101 | + return {"hardware": entity_summary(hw), "memory_options_gb": [], "items": [], "estimated": True, "note": "no memory_gb recorded for this hardware — nothing is estimated"} | |
| 102 | + mem = memory_gb if memory_gb is not None else max(options) | |
| 103 | + where = "e.entity_type = 'model' and e.merged_into is null and e.attributes ? 'parameter_count'" | |
| 104 | + params: dict[str, Any] = {} | |
| 105 | + if openness: | |
| 106 | + where += " and e.attributes->>'openness' = any(cast(:openness as text[]))" | |
| 107 | + params["openness"] = openness_values(openness) | |
| 108 | + rows = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where {where} order by {attr_num('parameter_count')} desc nulls last limit 3000", **params) | |
| 109 | + items = [] | |
| 110 | + for r in rows: | |
| 111 | + f = hf.fit_detailed(r["attributes"], mem, quant=q, context=context, gpu_count=gpu_count) | |
| 112 | + if f: | |
| 113 | + items.append({"model": entity_summary(r), **f}) | |
| 114 | + items.sort(key=lambda x: (not x["fits"], -(x.get("parameter_count") or 0) if x["fits"] else (x.get("parameter_count") or 0))) | |
| 115 | + return {"hardware": entity_summary(hw), "memory_options_gb": options, "inputs": {"memory_gb": mem, "quant": q, "context": context, "gpu_count": gpu_count}, "estimated": True, | |
| 116 | + "assumptions": hf.ASSUMPTIONS, "runtimes": (hw.get("attributes") or {}).get("runtimes"), "counts": {"fits": sum(1 for i in items if i["fits"]), "evaluated": len(items)}, "items": items[:limit]} | |
| 117 | + | |
| 118 | + | |
| 89 | 119 | @router.get("/{slug}") |
| 90 | 120 | @cached(300) |
| 91 | 121 | async def get_hardware(request: Request, slug: str) -> dict[str, Any]: |
added
src/aiatlas/api/routers/intelligence.py
+565 −0
@@ -0,0 +1,565 @@ | ||
| 1 | +"""Intelligence surfaces (API 1.1) — deterministic, no LLM, no composite scores: | |
| 2 | +/frontier · /pareto · /pulse · /open · /find-a-model · /run-locally · /time-machine.""" | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import statistics | |
| 6 | +from collections import defaultdict | |
| 7 | +from datetime import UTC, datetime, timedelta | |
| 8 | +from datetime import time as dtime | |
| 9 | +from typing import Any | |
| 10 | + | |
| 11 | +from fastapi import APIRouter, Query, Request | |
| 12 | + | |
| 13 | +from aiatlas.api.common import ( | |
| 14 | + CLAIM_COLS, | |
| 15 | + CLAIM_FROM, | |
| 16 | + DOWNLOADABLE_CATEGORIES, | |
| 17 | + ENTITY_COLS, | |
| 18 | + ENTITY_FROM, | |
| 19 | + EVENT_COLS, | |
| 20 | + EVENT_FROM, | |
| 21 | + OPEN_CATEGORIES, | |
| 22 | + PRICE_COLS, | |
| 23 | + PRICE_FROM, | |
| 24 | + ApiError, | |
| 25 | + cached, | |
| 26 | + change_event, | |
| 27 | + csv, | |
| 28 | + deployment_row, | |
| 29 | + entity_summary, | |
| 30 | + num_expr, | |
| 31 | + openness_values, | |
| 32 | + parse_date, | |
| 33 | + resolve_entity, | |
| 34 | + resolve_id, | |
| 35 | +) | |
| 36 | +from aiatlas.db import connection, fetch_all, fetch_one, fetch_val | |
| 37 | +from aiatlas.ontology.licenses import LICENSES, normalize_license | |
| 38 | +from aiatlas.services import hardware_fit as hf | |
| 39 | +from aiatlas.services.finder import USE_CASES, find_models | |
| 40 | +from aiatlas.services.frontier import ( | |
| 41 | + FRONTIER_METHODOLOGY, | |
| 42 | + all_primary_groups, | |
| 43 | + benchmark_meta, | |
| 44 | + frontier_model_ids, | |
| 45 | + group_rows, | |
| 46 | + group_summary, | |
| 47 | + leader_at, | |
| 48 | + leaderboard_rows, | |
| 49 | + load_results, | |
| 50 | + primary_group, | |
| 51 | + rank_rows, | |
| 52 | +) | |
| 53 | +from aiatlas.services.pareto import pareto_frontier | |
| 54 | + | |
| 55 | +router = APIRouter(prefix="/api/v1", tags=["intelligence"]) | |
| 56 | +PARAMS = num_expr("e.attributes->>'parameter_count'") | |
| 57 | +CONTEXT = num_expr("e.attributes->>'context_length'") | |
| 58 | +QUALITY_BENCHMARKS = ("artificial-analysis-intelligence-index", "gpqa") | |
| 59 | +PARETO_X = {"output_price": "cheapest current output price (USD / 1M tokens)", "input_price": "cheapest current input price (USD / 1M tokens)", | |
| 60 | + "parameter_count": "total parameters", "context_length": "context window (tokens)", "memory_estimate": "ESTIMATED memory at 4-bit, 8K context (GB)"} | |
| 61 | + | |
| 62 | + | |
| 63 | +def _f(v: Any) -> float | None: | |
| 64 | + if v is None or isinstance(v, bool): | |
| 65 | + return None | |
| 66 | + try: | |
| 67 | + return float(v) | |
| 68 | + except (TypeError, ValueError): | |
| 69 | + return None | |
| 70 | + | |
| 71 | + | |
| 72 | +def _mods(attrs: dict[str, Any]) -> set[str]: | |
| 73 | + out: set[str] = set() | |
| 74 | + for k in ("modalities", "modalities_input", "modalities_output"): | |
| 75 | + v = attrs.get(k) | |
| 76 | + if isinstance(v, list): | |
| 77 | + out |= {str(x).lower() for x in v} | |
| 78 | + return {("image" if m == "vision" else "document" if m == "pdf" else m) for m in out} | |
| 79 | + | |
| 80 | + | |
| 81 | +async def _cheapest_prices(conn: Any, model_ids: list[str] | None = None) -> dict[str, dict[str, Any]]: | |
| 82 | + where = "p.valid_to is null" + (" and p.model_id = any(cast(:ids as text[]))" if model_ids is not None else "") | |
| 83 | + rows = await fetch_all(conn, f"""select p.model_id, min(p.output_per_mtok) filter (where p.output_per_mtok > 0) as min_output, min(p.input_per_mtok) filter (where p.input_per_mtok > 0) as min_input, | |
| 84 | + count(distinct p.provider_id) as providers, max(p.context_length) as max_ctx | |
| 85 | + from prices p where {where} group by 1""", ids=model_ids) | |
| 86 | + return {r["model_id"]: r for r in rows} | |
| 87 | + | |
| 88 | + | |
| 89 | +async def _cheapest_offer(conn: Any, model_id: str, field: str = "output") -> dict[str, Any] | None: | |
| 90 | + col = "p.output_per_mtok" if field == "output" else "p.input_per_mtok" | |
| 91 | + row = await fetch_one(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.model_id = :id and p.valid_to is null and {col} > 0 order by {col} asc limit 1", id=model_id) | |
| 92 | + return deployment_row(row) if row else None | |
| 93 | + | |
| 94 | + | |
| 95 | +# ------------------------------------------------------------------------------------------------------------------ /frontier | |
| 96 | + | |
| 97 | + | |
| 98 | +@router.get("/frontier") | |
| 99 | +@cached(300) | |
| 100 | +async def frontier(request: Request, limit: int = Query(12, ge=1, le=50)) -> dict[str, Any]: | |
| 101 | + now = datetime.now(UTC) | |
| 102 | + async with connection() as conn: | |
| 103 | + groups = await all_primary_groups(conn) | |
| 104 | + fids, composition = await frontier_model_ids(conn) | |
| 105 | + major = await fetch_all(conn, f""" | |
| 106 | + select distinct on (e.id) {EVENT_COLS}, ev.occurred_at, ev.is_backfill from {EVENT_FROM} left join sources s on s.id = ev.source_id | |
| 107 | + where ev.event_type in ('NEW_MODEL','RELEASE') and ev.importance >= 3 and e.entity_type = 'model' and e.merged_into is null and coalesce(s.tier, 2) <= 2 | |
| 108 | + and (ev.is_backfill = false or e.attributes->>'release_date' >= :since) | |
| 109 | + order by e.id, ev.occurred_at desc""", since=(now - timedelta(days=60)).date().isoformat()) | |
| 110 | + major.sort(key=lambda r: (str((r.get("e_attributes") or {}).get("release_date") or ""), r["occurred_at"]), reverse=True) | |
| 111 | + cheapest = await fetch_one(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.valid_to is null and p.output_per_mtok > 0 and p.model_id = any(cast(:ids as text[])) order by p.output_per_mtok asc limit 1", ids=sorted(fids)) | |
| 112 | + cheapest_1m = await fetch_one(conn, f"""select {PRICE_COLS} from {PRICE_FROM} where p.valid_to is null and p.output_per_mtok > 0 and p.model_id = any(cast(:ids as text[])) | |
| 113 | + and coalesce(p.context_length, case when m.attributes->>'context_length' ~ '^[0-9]+$' then (m.attributes->>'context_length')::bigint end) >= 1000000 | |
| 114 | + order by p.output_per_mtok asc limit 1""", ids=sorted(fids)) | |
| 115 | + ctx_rows = await fetch_all(conn, f"select {ENTITY_COLS}, {CONTEXT} as ctx from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and {CONTEXT} is not null order by {CONTEXT} desc, e.canonical_name limit :lim", lim=limit) | |
| 116 | + open_rows = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and e.attributes->>'openness' = any(cast(:o as text[]))", o=list(OPEN_CATEGORIES)) | |
| 117 | + movements = await fetch_all(conn, f"""select {EVENT_COLS}, ev.occurred_at from {EVENT_FROM} where ev.is_backfill = false and ev.occurred_at > now() - interval '30 days' | |
| 118 | + and (ev.event_type in ('BENCHMARK_UPDATED','BENCHMARK_LEADER_CHANGED','NEW_BENCHMARK_LEADER') or ev.event_type = 'PRICE_CHANGED') | |
| 119 | + order by ev.occurred_at desc limit 500""") | |
| 120 | + all_prices = await _cheapest_prices(conn) | |
| 121 | + # ranks per model across primary groups | |
| 122 | + ranks: dict[str, dict[str, int]] = defaultdict(dict) | |
| 123 | + for bid, g in groups.items(): | |
| 124 | + for r in rank_rows(g["rows"], g["higher_is_better"]): | |
| 125 | + ranks[r["model_id"]][g["benchmark"]["slug"]] = r["rank"] | |
| 126 | + bench_frontier = [] | |
| 127 | + agentic = [] | |
| 128 | + for bid, g in sorted(groups.items(), key=lambda kv: -kv[1]["n"]): | |
| 129 | + lb = leaderboard_rows(g) | |
| 130 | + if g["n"] >= 20 and lb: | |
| 131 | + leader, second = lb[0], (lb[1] if len(lb) > 1 else None) | |
| 132 | + bench_frontier.append({"benchmark": {k: g["benchmark"].get(k) for k in ("id", "slug", "name", "category")}, "group": group_summary(g), "leader": leader, "second": second, | |
| 133 | + "gap": round(leader["score"] - second["score"], 3) if second else None}) | |
| 134 | + if (g["benchmark"].get("category") or "").lower() == "agentic" and lb: | |
| 135 | + agentic.append({"benchmark": {k: g["benchmark"].get(k) for k in ("id", "slug", "name")}, "group": group_summary(g), "leaders": lb[:5]}) | |
| 136 | + open_frontier = [] | |
| 137 | + for r in open_rows: | |
| 138 | + attrs = r["attributes"] or {} | |
| 139 | + rk = ranks.get(r["id"], {}) | |
| 140 | + best = min(rk.values()) if rk else None | |
| 141 | + open_frontier.append({"model": entity_summary(r), "best_rank": best, "best_rank_on": min(rk, key=rk.get) if rk else None, "parameter_count": _f(attrs.get("parameter_count")), | |
| 142 | + "context_length": _f(attrs.get("context_length")), "ranks": dict(sorted(rk.items())), "_k": (best if best is not None else 10_000, -(_f(attrs.get("parameter_count")) or 0))}) | |
| 143 | + open_frontier.sort(key=lambda x: x["_k"]) | |
| 144 | + for x in open_frontier: | |
| 145 | + x.pop("_k") | |
| 146 | + # efficiency frontier: quality (index or GPQA primary group) vs cheapest output price | |
| 147 | + quality_group = None | |
| 148 | + for slug in QUALITY_BENCHMARKS: | |
| 149 | + quality_group = next((g for g in groups.values() if g["benchmark"]["slug"] == slug), None) | |
| 150 | + if quality_group: | |
| 151 | + break | |
| 152 | + eff_points = [] | |
| 153 | + if quality_group: | |
| 154 | + for r in rank_rows(quality_group["rows"], quality_group["higher_is_better"]): | |
| 155 | + pr = all_prices.get(r["model_id"]) | |
| 156 | + if pr and pr["min_output"] is not None: | |
| 157 | + eff_points.append({"id": r["model_id"], "model": {"id": r["model_id"], "slug": r["model_slug"], "name": r["model_name"]}, "x": float(pr["min_output"]), "y": float(r["score"]), | |
| 158 | + "rank": r["rank"], "trust_level": r["trust_level"]}) | |
| 159 | + eff_front = set(pareto_frontier(eff_points, maximize_y=quality_group["higher_is_better"] if quality_group else True)) | |
| 160 | + multimodal = [] | |
| 161 | + for mid, rk in ranks.items(): | |
| 162 | + if min(rk.values()) > 10: | |
| 163 | + continue | |
| 164 | + row = next((r for g in groups.values() for r in g["rows"] if r["model_id"] == mid), None) | |
| 165 | + if not row: | |
| 166 | + continue | |
| 167 | + mods = _mods(row.get("model_attrs") or {}) | |
| 168 | + if len(mods) >= 3: | |
| 169 | + multimodal.append({"model": {"id": mid, "slug": row["model_slug"], "name": row["model_name"]}, "modalities": sorted(mods), "top10_on": sorted(b for b, k in rk.items() if k <= 10)}) | |
| 170 | + moves = [] | |
| 171 | + for r in movements: | |
| 172 | + ev = change_event(r) | |
| 173 | + if ev["event_type"] == "PRICE_CHANGED": | |
| 174 | + o, n = _f(ev.get("old_value")), _f(ev.get("new_value")) | |
| 175 | + if o and n is not None and abs(n - o) / o >= 0.20: | |
| 176 | + ev["percent_change"] = round((n - o) / o * 100, 1) | |
| 177 | + moves.append(ev) | |
| 178 | + else: | |
| 179 | + moves.append(ev) | |
| 180 | + return { | |
| 181 | + "latest_major_models": [change_event(r) | {"occurred_at": r["occurred_at"], "is_backfill": r["is_backfill"]} for r in major[:limit]], | |
| 182 | + "benchmark_frontier": bench_frontier, | |
| 183 | + "price_frontier": {"cheapest_output": _deploy_or_none(cheapest), "cheapest_output_1m_context": _deploy_or_none(cheapest_1m), "frontier_models": len(fids), "composition": composition}, | |
| 184 | + "context_frontier": [{"model": entity_summary(r), "context_length": r["ctx"]} for r in ctx_rows], | |
| 185 | + "open_weight_frontier": {"items": open_frontier[:limit], "dimensions": ["best_rank", "parameter_count", "context_length"], "note": "sorted by best benchmark rank then parameters; no composite"}, | |
| 186 | + "efficiency_frontier": {"quality": {"benchmark": quality_group["benchmark"]["slug"], "group": group_summary(quality_group)} if quality_group else None, "x": "cheapest current output price (USD / 1M tokens)", | |
| 187 | + "points": [{**p, "pareto": p["id"] in eff_front} for p in sorted(eff_points, key=lambda p: p["x"])], "frontier": sorted(eff_front)}, | |
| 188 | + "agentic_frontier": agentic, | |
| 189 | + "multimodal_frontier": sorted(multimodal, key=lambda x: (-len(x["top10_on"]), x["model"]["name"] or ""))[:limit], | |
| 190 | + "recent_frontier_movements": moves[:50], | |
| 191 | + "generated_at": now, | |
| 192 | + "methodology": FRONTIER_METHODOLOGY + " benchmark_frontier lists the primary comparability group of every benchmark with ≥ 20 current results; efficiency_frontier is the " | |
| 193 | + "Pareto set (maximise quality score, minimise cheapest current output price); recent movements are non-backfill benchmark events and price moves ≥ 20% in 30 days. " | |
| 194 | + "Nothing here is a composite ranking.", | |
| 195 | + } | |
| 196 | + | |
| 197 | + | |
| 198 | +def _deploy_or_none(row: dict[str, Any] | None) -> dict[str, Any] | None: | |
| 199 | + return deployment_row(row) if row else None | |
| 200 | + | |
| 201 | + | |
| 202 | +# ------------------------------------------------------------------------------------------------------------------ /pareto | |
| 203 | + | |
| 204 | + | |
| 205 | +@router.get("/pareto") | |
| 206 | +@cached(300) | |
| 207 | +async def pareto(request: Request, benchmark: str = Query(...), x: str = Query("output_price"), y: str = Query("score"), metric: str | None = None, config_key: str | None = None, | |
| 208 | + org: str | None = None, family: str | None = None, openness: str | None = None) -> dict[str, Any]: | |
| 209 | + if x == "latency": | |
| 210 | + raise ApiError(400, "x=latency is not available: AI Atlas does not store latency measurements (nothing is estimated for it)") | |
| 211 | + if x not in PARETO_X: | |
| 212 | + raise ApiError(400, f"x must be one of {', '.join(PARETO_X)}") | |
| 213 | + if y != "score": | |
| 214 | + raise ApiError(400, "y must be 'score'") | |
| 215 | + async with connection() as conn: | |
| 216 | + bench = await resolve_entity(conn, benchmark, ("benchmark",), aliases=True) | |
| 217 | + rows = await load_results(conn, benchmark_ids=[bench["id"]]) | |
| 218 | + groups = list(group_rows(rows).values()) | |
| 219 | + g = None | |
| 220 | + if metric or config_key: | |
| 221 | + cands = [gg for gg in groups if (not metric or gg["metric"] == metric.lower()) and (not config_key or gg["config_key"] == config_key)] | |
| 222 | + g = max(cands, key=lambda gg: gg["model_count"]) if cands else None | |
| 223 | + else: | |
| 224 | + g = primary_group(bench.get("attributes"), groups) | |
| 225 | + if not g: | |
| 226 | + return {"benchmark": entity_summary(bench), "points": [], "frontier": [], "groups": [group_summary(x) for x in groups], "note": "no current results in the requested group"} | |
| 227 | + ranked = rank_rows(g["rows"], g["higher_is_better"]) | |
| 228 | + ids = [r["model_id"] for r in ranked] | |
| 229 | + prices = await _cheapest_prices(conn, ids) if x in ("output_price", "input_price") else {} | |
| 230 | + offers: dict[str, dict[str, Any]] = {} | |
| 231 | + if x in ("output_price", "input_price"): | |
| 232 | + col = "p.output_per_mtok" if x == "output_price" else "p.input_per_mtok" | |
| 233 | + for pr in await fetch_all(conn, f"select distinct on (p.model_id) {PRICE_COLS} from {PRICE_FROM} where p.valid_to is null and {col} > 0 and p.model_id = any(cast(:ids as text[])) order by p.model_id, {col} asc", ids=ids): | |
| 234 | + offers[pr["m_id"]] = deployment_row(pr) | |
| 235 | + org_id = await resolve_id(conn, org) if org else None | |
| 236 | + fam_ids: set[str] | None = None | |
| 237 | + if family: | |
| 238 | + fr = await fetch_all(conn, "select e.id from entities e left join entities f on f.id = e.family_id where e.entity_type = 'model' and (f.slug = :f or f.id = :f or e.attributes->>'family' ilike :f)", f=family) | |
| 239 | + fam_ids = {r["id"] for r in fr} | |
| 240 | + open_vals = set(openness_values(openness)) if openness else None | |
| 241 | + points = [] | |
| 242 | + for r in ranked: | |
| 243 | + attrs = r.get("model_attrs") or {} | |
| 244 | + if org_id and r.get("organization_id") != org_id: | |
| 245 | + continue | |
| 246 | + if fam_ids is not None and r["model_id"] not in fam_ids: | |
| 247 | + continue | |
| 248 | + if open_vals and str(attrs.get("openness") or "") not in open_vals: | |
| 249 | + continue | |
| 250 | + xv: float | None | |
| 251 | + provider = None | |
| 252 | + if x == "output_price": | |
| 253 | + pr = prices.get(r["model_id"]) | |
| 254 | + xv = _f(pr["min_output"]) if pr else None | |
| 255 | + provider = (offers.get(r["model_id"]) or {}).get("provider") | |
| 256 | + elif x == "input_price": | |
| 257 | + pr = prices.get(r["model_id"]) | |
| 258 | + xv = _f(pr["min_input"]) if pr else None | |
| 259 | + provider = (offers.get(r["model_id"]) or {}).get("provider") | |
| 260 | + elif x == "parameter_count": | |
| 261 | + xv = _f(attrs.get("parameter_count")) | |
| 262 | + elif x == "context_length": | |
| 263 | + xv = _f(attrs.get("context_length")) | |
| 264 | + else: | |
| 265 | + pc = hf.parameter_count(attrs) | |
| 266 | + xv = hf.estimate_memory_gb(pc, "4bit", 8192) if pc else None | |
| 267 | + if xv is None: | |
| 268 | + continue | |
| 269 | + points.append({"id": r["model_id"], "model": {"id": r["model_id"], "slug": r["model_slug"], "name": r["model_name"], "organization": r.get("org_name"), "openness": attrs.get("openness")}, | |
| 270 | + "x": xv, "y": float(r["score"]), "rank": r["rank"], "trust_level": r["trust_level"], "config": {k: v for k, v in (r.get("config") or {}).items() if k in ("reasoning_effort", "reasoning", "variant", "evaluator")}, | |
| 271 | + **({"provider": provider} if provider else {}), **({"estimated": True} if x == "memory_estimate" else {})}) | |
| 272 | + front = pareto_frontier(points, maximize_y=g["higher_is_better"]) | |
| 273 | + fset = set(front) | |
| 274 | + for p in points: | |
| 275 | + p["pareto"] = p["id"] in fset | |
| 276 | + return {"benchmark": entity_summary(bench), "group": group_summary(g), "groups": [group_summary(gg) for gg in groups], "x": {"key": x, "label": PARETO_X[x]}, "y": {"key": "score", "label": f"{g['metric']} on {bench['canonical_name']}"}, | |
| 277 | + "points": sorted(points, key=lambda p: p["x"]), "frontier": front, | |
| 278 | + "methodology": f"Points are the best current row per canonical model in comparability group '{g['label']}'. Price = cheapest current offer across providers " | |
| 279 | + f"(the provider shown). Pareto frontier maximises the score and minimises x; exact ties are all kept. memory_estimate is an estimate (see /methodology)."} | |
| 280 | + | |
| 281 | + | |
| 282 | +# ------------------------------------------------------------------------------------------------------------------ /pulse | |
| 283 | + | |
| 284 | + | |
| 285 | +@router.get("/pulse") | |
| 286 | +@cached(120) | |
| 287 | +async def pulse(request: Request, days: int = Query(7, ge=1, le=90)) -> dict[str, Any]: | |
| 288 | + now = datetime.now(UTC) | |
| 289 | + since = now - timedelta(days=days) | |
| 290 | + async with connection() as conn: | |
| 291 | + c = await fetch_one(conn, f""" | |
| 292 | + with ev as (select ev.*, e.entity_type as et, e.attributes as attrs from change_events ev left join entities e on e.id = ev.entity_id | |
| 293 | + where ev.is_backfill = false and ev.occurred_at > :since) | |
| 294 | + select (select count(distinct entity_id) from ev where event_type = 'NEW_MODEL' and et = 'model') as new_models, | |
| 295 | + (select count(distinct entity_id) from ev where event_type = 'NEW_MODEL' and et = 'model' and attrs->>'openness' in ('open-weights','open-source')) as new_open_models, | |
| 296 | + (select count(*) from entities where entity_type = 'artifact' and merged_into is null and first_seen_at > :since) as new_artifacts, | |
| 297 | + (select count(distinct entity_id) from ev where event_type = 'NEW_PAPER') as new_papers, | |
| 298 | + (select count(*) from ev where event_type = 'PROVIDER_LISTED') as provider_listings, | |
| 299 | + (select count(*) from ev where event_type = 'PROVIDER_DELISTED') as provider_delistings, | |
| 300 | + (select count(*) from ev where event_type = 'PRICE_CHANGED') as price_changes, | |
| 301 | + (select count(distinct entity_id) from ev where event_type = 'NEW_MODEL' and et = 'model' and {num_expr("attrs->>'context_length'")} >= 1000000) as new_models_1m_context, | |
| 302 | + (select count(*) from ev where event_type = 'DOCUMENT_CHANGED') as documents_changed, | |
| 303 | + (select count(distinct d.source_id) from snapshots s join documents d on d.id = s.document_id where s.observed_at > :since) as sources_observed, | |
| 304 | + (select count(*) from ev where event_type <> 'DOCUMENT_CHANGED') as events_total""", since=since) | |
| 305 | + pct = await fetch_all(conn, "select old_value, new_value from change_events where is_backfill = false and occurred_at > :since and event_type = 'PRICE_CHANGED'", since=since) | |
| 306 | + rows = await load_results(conn, current_only=False) | |
| 307 | + meta = await benchmark_meta(conn) | |
| 308 | + moves = [] | |
| 309 | + for r in pct: | |
| 310 | + o, n = _f(r["old_value"]), _f(r["new_value"]) | |
| 311 | + if o and n is not None: | |
| 312 | + moves.append((n - o) / o * 100) | |
| 313 | + by_bench: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 314 | + for r in rows: | |
| 315 | + by_bench[r["benchmark_id"]].append(r) | |
| 316 | + new_leaders = [] | |
| 317 | + for bid, brows in by_bench.items(): | |
| 318 | + m = meta.get(bid, {}) | |
| 319 | + la, lb = leader_at(brows, since, m.get("attributes")), leader_at(brows, now, m.get("attributes")) | |
| 320 | + if lb and (la is None or la["model"]["id"] != lb["model"]["id"]): | |
| 321 | + new_leaders.append({"benchmark": {"id": bid, "slug": m.get("slug"), "name": m.get("name")}, "previous": la, "current": lb}) | |
| 322 | + c = c or {} | |
| 323 | + counters = { | |
| 324 | + "new_models": {"value": int(c.get("new_models") or 0), "definition": "canonical models with a NEW_MODEL event that occurred in the window (not back-filled)"}, | |
| 325 | + "new_open_weight_models": {"value": int(c.get("new_open_models") or 0), "definition": "subset of new_models with openness open-weights / open-source"}, | |
| 326 | + "new_artifacts": {"value": int(c.get("new_artifacts") or 0), "definition": "artifact entities (checkpoints, quantisations, conversions) first seen in the window"}, | |
| 327 | + "new_papers": {"value": int(c.get("new_papers") or 0), "definition": "papers with a NEW_PAPER event that occurred in the window"}, | |
| 328 | + "provider_listings": {"value": int(c.get("provider_listings") or 0), "definition": "PROVIDER_LISTED events in the window"}, | |
| 329 | + "provider_delistings": {"value": int(c.get("provider_delistings") or 0), "definition": "PROVIDER_DELISTED events in the window"}, | |
| 330 | + "price_changes": {"value": int(c.get("price_changes") or 0), "median_percent": round(statistics.median(moves), 2) if moves else None, | |
| 331 | + "definition": "PRICE_CHANGED events in the window; median % change computed from the events' old/new numeric values when both are present"}, | |
| 332 | + "new_models_1m_context": {"value": int(c.get("new_models_1m_context") or 0), "definition": "new_models whose context_length is at least 1 000 000 tokens"}, | |
| 333 | + "new_benchmark_leaders": {"value": len(new_leaders), "items": new_leaders, "definition": "benchmarks whose primary-group leader (computed from results observed by each date) changed over the window"}, | |
| 334 | + "documents_changed": {"value": int(c.get("documents_changed") or 0), "definition": "DOCUMENT_CHANGED events in the window"}, | |
| 335 | + "sources_observed": {"value": int(c.get("sources_observed") or 0), "definition": "distinct sources with at least one snapshot taken in the window"}, | |
| 336 | + "events_total": {"value": int(c.get("events_total") or 0), "definition": "all non-backfill events (excluding source-document changes) that occurred in the window"}, | |
| 337 | + } | |
| 338 | + return {"days": days, "since": since, "until": now, "counters": counters, | |
| 339 | + "note": "Deterministic counters over events that OCCURRED in the window and are not back-filled history; each counter carries its own definition."} | |
| 340 | + | |
| 341 | + | |
| 342 | +# ------------------------------------------------------------------------------------------------------------------ /open | |
| 343 | + | |
| 344 | + | |
| 345 | +@router.get("/open") | |
| 346 | +@cached(300) | |
| 347 | +async def open_models(request: Request, sort: str = Query("release", pattern="^(release|params|context|rank|name|downloads)$"), license: str | None = None, | |
| 348 | + min_params: float | None = Query(None, ge=0), max_params: float | None = Query(None, ge=0), min_context: int | None = Query(None, ge=0), | |
| 349 | + modality: str | None = None, days: int | None = Query(None, ge=1, le=3650), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), | |
| 350 | + openness: str | None = None) -> dict[str, Any]: | |
| 351 | + """Open-weight / open-source / restricted-weights canonical models with licence permissions, observed dimensions, best results, estimated hardware fit.""" | |
| 352 | + where = ["e.entity_type = 'model'", "e.merged_into is null", "e.attributes->>'openness' = any(cast(:o as text[]))"] | |
| 353 | + params: dict[str, Any] = {"o": openness_values(openness) if openness else list(DOWNLOADABLE_CATEGORIES)} | |
| 354 | + if license: | |
| 355 | + from aiatlas.api.routers.models import license_match_sql, license_params | |
| 356 | + | |
| 357 | + where.append(license_match_sql()) | |
| 358 | + params.update(license_params(license)) | |
| 359 | + if min_params is not None: | |
| 360 | + where.append(f"{PARAMS} >= :minp") | |
| 361 | + params["minp"] = float(min_params) | |
| 362 | + if max_params is not None: | |
| 363 | + where.append(f"{PARAMS} <= :maxp") | |
| 364 | + params["maxp"] = float(max_params) | |
| 365 | + if min_context is not None: | |
| 366 | + where.append(f"{CONTEXT} >= :minc") | |
| 367 | + params["minc"] = float(min_context) | |
| 368 | + if modality: | |
| 369 | + where.append("(e.attributes->'modalities' ? :mod or e.attributes->'modalities_input' ? :mod or e.attributes->'modalities_output' ? :mod)") | |
| 370 | + params["mod"] = modality | |
| 371 | + if days: | |
| 372 | + where.append("(e.attributes->>'release_date' >= :since or e.first_seen_at > now() - make_interval(days => :days))") | |
| 373 | + params["since"] = (datetime.now(UTC) - timedelta(days=days)).date().isoformat() | |
| 374 | + params["days"] = days | |
| 375 | + order = {"release": "e.attributes->>'release_date' desc nulls last", "params": f"{PARAMS} desc nulls last", "context": f"{CONTEXT} desc nulls last", "name": "e.canonical_name asc", | |
| 376 | + "downloads": num_expr("e.attributes->>'metric.downloads'") + " desc nulls last", "rank": "e.canonical_name asc"}[sort] | |
| 377 | + where_sql = " and ".join(where) | |
| 378 | + async with connection() as conn: | |
| 379 | + rows = await fetch_all(conn, f"select {ENTITY_COLS}, e.family_id from {ENTITY_FROM} where {where_sql} order by {order}, e.id limit :lim offset :off", lim=limit if sort != "rank" else 2000, off=0 if sort == "rank" else offset, **params) | |
| 380 | + total = await fetch_val(conn, f"select count(*) from {ENTITY_FROM} where {where_sql}", **params) | |
| 381 | + groups = await all_primary_groups(conn) | |
| 382 | + prices = await _cheapest_prices(conn, [r["id"] for r in rows]) | |
| 383 | + summary = await fetch_one(conn, """select jsonb_object_agg(k, n) as by_cat from (select attributes->>'openness' as k, count(*) as n from entities | |
| 384 | + where entity_type = 'model' and merged_into is null and attributes->>'openness' in ('open-weights','open-source','restricted-weights','restricted') group by 1) x""") | |
| 385 | + lic_rows = await fetch_all(conn, """select coalesce(attributes->>'license_key', attributes->>'license') as raw, count(*) as n from entities where entity_type = 'model' and merged_into is null | |
| 386 | + and attributes->>'openness' in ('open-weights','open-source','restricted-weights','restricted') and (attributes ? 'license' or attributes ? 'license_key') group by 1""") | |
| 387 | + new_30d = await fetch_val(conn, """select count(*) from entities where entity_type = 'model' and merged_into is null and attributes->>'openness' in ('open-weights','open-source','restricted-weights','restricted') | |
| 388 | + and (attributes->>'release_date' >= to_char((now() at time zone 'UTC') - interval '30 days', 'YYYY-MM-DD') or first_seen_at > now() - interval '30 days')""") | |
| 389 | + ranks: dict[str, dict[str, int]] = defaultdict(dict) | |
| 390 | + for g in groups.values(): | |
| 391 | + for r in rank_rows(g["rows"], g["higher_is_better"]): | |
| 392 | + ranks[r["model_id"]][g["benchmark"]["slug"]] = r["rank"] | |
| 393 | + items = [] | |
| 394 | + for r in rows: | |
| 395 | + attrs = r["attributes"] or {} | |
| 396 | + key = attrs.get("license_key") or normalize_license(attrs.get("license")) | |
| 397 | + info = LICENSES.get(key) if key else None | |
| 398 | + rk = ranks.get(r["id"], {}) | |
| 399 | + best = sorted(rk.items(), key=lambda kv: kv[1])[:3] | |
| 400 | + pr = prices.get(r["id"]) | |
| 401 | + items.append({"model": entity_summary(r), "licence": {**info.as_dict(), "raw": attrs.get("license")} if info else {"key": None, "raw": attrs.get("license"), "note": "not classified"}, | |
| 402 | + "dimensions": {"parameter_count": _f(attrs.get("parameter_count")), "active_parameter_count": _f(attrs.get("active_parameter_count")), "context_length": _f(attrs.get("context_length")), | |
| 403 | + "modalities": sorted(_mods(attrs)), "release_date": attrs.get("release_date"), "openness": attrs.get("openness"), "downloads": _f(attrs.get("metric.downloads"))}, | |
| 404 | + "best_results": [{"benchmark": b, "rank": k} for b, k in best], "best_rank": best[0][1] if best else None, | |
| 405 | + "hardware_fit": {"4bit_64gb": hf.fit_detailed(attrs, 64, quant="4bit", context=8192), "8bit_128gb": hf.fit_detailed(attrs, 128, quant="8bit", context=8192), "estimated": True}, | |
| 406 | + "providers": int(pr["providers"]) if pr else 0, "cheapest_output_per_mtok": _f(pr["min_output"]) if pr else None}) | |
| 407 | + if sort == "rank": | |
| 408 | + items.sort(key=lambda x: (x["best_rank"] if x["best_rank"] is not None else 10_000, x["model"]["name"] or "")) | |
| 409 | + items = items[offset:offset + limit] | |
| 410 | + lic_counts: dict[str, int] = defaultdict(int) | |
| 411 | + for lr in lic_rows: | |
| 412 | + k = lr["raw"] if lr["raw"] in LICENSES else normalize_license(lr["raw"]) | |
| 413 | + lic_counts[k or f"raw:{lr['raw']}"] += int(lr["n"]) | |
| 414 | + return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset, | |
| 415 | + "summary": {"by_category": (summary or {}).get("by_cat") or {}, "by_license_top": [{"key": k, "label": LICENSES[k].label if k in LICENSES else k, "models": n} for k, n in sorted(lic_counts.items(), key=lambda kv: -kv[1])[:12]], | |
| 416 | + "new_30d": int(new_30d or 0)}, | |
| 417 | + "note": "Universe = canonical models whose weights can be downloaded (open-weights, open-source, restricted-weights). hardware_fit values are ESTIMATES (see /methodology); " | |
| 418 | + "best_results are ranks inside each benchmark's primary comparability group — no composite score."} | |
| 419 | + | |
| 420 | + | |
| 421 | +# ------------------------------------------------------------------------------------------------------------------ /find-a-model | |
| 422 | + | |
| 423 | + | |
| 424 | +@router.get("/find-a-model") | |
| 425 | +@cached(300) | |
| 426 | +async def find_a_model(request: Request, use_case: str | None = Query(None), deployment: str = Query("any", pattern="^(local|api|any)$"), memory_gb: float | None = Query(None, gt=0, le=100000), | |
| 427 | + quant: str = Query("4bit"), context_min: int | None = Query(None, ge=0), license: str = Query("any", pattern="^(commercial|any)$"), openness: str | None = None, | |
| 428 | + max_input_price: float | None = Query(None, ge=0), max_output_price: float | None = Query(None, ge=0), modalities: str | None = None, | |
| 429 | + limit: int = Query(30, ge=1, le=100)) -> dict[str, Any]: | |
| 430 | + if use_case and use_case not in USE_CASES: | |
| 431 | + raise ApiError(400, f"use_case must be one of {', '.join(USE_CASES)}") | |
| 432 | + async with connection() as conn: | |
| 433 | + out = await find_models(conn, use_case=use_case, deployment=deployment, memory_gb=memory_gb, quant=quant, context_min=context_min, license=license, | |
| 434 | + openness=openness_values(openness) if openness else None, max_input_price=max_input_price, max_output_price=max_output_price, | |
| 435 | + modalities=csv(modalities), limit=limit) | |
| 436 | + if deployment != "local": | |
| 437 | + ids = [m["model"]["id"] for m in out["matches"]] | |
| 438 | + rows = await fetch_all(conn, f"""select * from (select {PRICE_COLS}, row_number() over (partition by p.model_id order by p.output_per_mtok asc nulls last) as rn | |
| 439 | + from {PRICE_FROM} where p.valid_to is null and p.model_id = any(cast(:ids as text[]))) x where rn <= 5""", ids=ids) | |
| 440 | + by: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 441 | + for r in rows: | |
| 442 | + by[r["m_id"]].append(deployment_row(r)) | |
| 443 | + for m in out["matches"]: | |
| 444 | + m["deployments"] = by.get(m["model"]["id"], []) | |
| 445 | + return out | |
| 446 | + | |
| 447 | + | |
| 448 | +# ------------------------------------------------------------------------------------------------------------------ /run-locally | |
| 449 | + | |
| 450 | + | |
| 451 | +@router.get("/run-locally") | |
| 452 | +@cached(300) | |
| 453 | +async def run_locally(request: Request, memory_gb: float = Query(..., gt=0, le=100000), gpu_count: int = Query(1, ge=1, le=8), quant: str = Query("4bit"), context: int = Query(8192, ge=0, le=10_000_000), | |
| 454 | + batch: int = Query(1, ge=1, le=256), platform: str = Query("any", pattern="^(apple|nvidia|amd|any)$"), use_case: str | None = None, | |
| 455 | + limit: int = Query(60, ge=1, le=300), openness: str | None = None) -> dict[str, Any]: | |
| 456 | + """Canonical models (and their compatible artifacts) whose ESTIMATED footprint fits `memory_gb × gpu_count`.""" | |
| 457 | + if gpu_count not in (1, 2, 4, 8): | |
| 458 | + raise ApiError(400, "gpu_count must be 1, 2, 4 or 8") | |
| 459 | + q = hf.normalize_quant(quant) | |
| 460 | + fmt_pref = {"apple": ("mlx", "gguf"), "nvidia": ("gguf", "awq", "gptq", "fp8", "nvfp4", "int4", "int8"), "amd": ("gguf", "mxfp4", "int8"), "any": ()}[platform] | |
| 461 | + where = ["e.entity_type = 'model'", "e.merged_into is null", "e.attributes ? 'parameter_count'", "e.attributes->>'openness' = any(cast(:o as text[]))"] | |
| 462 | + params: dict[str, Any] = {"o": openness_values(openness) if openness else list(DOWNLOADABLE_CATEGORIES)} | |
| 463 | + async with connection() as conn: | |
| 464 | + rows = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where {' and '.join(where)} order by {PARAMS} desc nulls last limit 3000", **params) | |
| 465 | + arts = await fetch_all(conn, f""" | |
| 466 | + select a.canonical_id as model_id, {ENTITY_COLS} from entities e join lateral (select e.canonical_id) a on true left join entities eo on eo.id = e.organization_id | |
| 467 | + where e.entity_type = 'artifact' and e.merged_into is null and e.canonical_id is not null | |
| 468 | + union all | |
| 469 | + select r.object_id as model_id, {ENTITY_COLS} from relations r join entities e on e.id = r.subject_id left join entities eo on eo.id = e.organization_id | |
| 470 | + where r.predicate = 'quantized_from' and r.valid_to is null and e.merged_into is null and (e.attributes->>'is_quantized' = 'true' or e.attributes ? 'quant_format')""") | |
| 471 | + groups = await all_primary_groups(conn) if use_case else {} | |
| 472 | + cat_models: set[str] | None = None | |
| 473 | + if use_case: | |
| 474 | + cat = {"coding": "coding", "reasoning": "reasoning", "agentic": "agentic", "vision": "multimodal", "math": "math"}.get(use_case, use_case) | |
| 475 | + cat_models = {r["model_id"] for g in groups.values() if (g["benchmark"].get("category") or "").lower() == cat for r in g["rows"]} | |
| 476 | + art_by: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 477 | + for a in arts: | |
| 478 | + art_by[a["model_id"]].append(a) | |
| 479 | + items = [] | |
| 480 | + for r in rows: | |
| 481 | + if cat_models is not None and r["id"] not in cat_models: | |
| 482 | + continue | |
| 483 | + attrs = r["attributes"] or {} | |
| 484 | + f = hf.fit_detailed(attrs, memory_gb, quant=q, context=context, batch=batch, gpu_count=gpu_count) | |
| 485 | + if not f: | |
| 486 | + continue | |
| 487 | + compatible = [] | |
| 488 | + for a in art_by.get(r["id"], []): | |
| 489 | + aa = a["attributes"] or {} | |
| 490 | + fmt = str(aa.get("quant_format") or "").lower() | |
| 491 | + if fmt_pref and fmt and fmt not in fmt_pref: | |
| 492 | + continue | |
| 493 | + size = hf.file_size_gb(aa) | |
| 494 | + af = hf.fit_detailed({**attrs, **{k: v for k, v in aa.items() if k in ("num_hidden_layers", "num_key_value_heads", "num_attention_heads", "head_dim", "hidden_size")}}, memory_gb, | |
| 495 | + quant=q, context=context, batch=batch, gpu_count=gpu_count, observed_size_gb=size) | |
| 496 | + compatible.append({"artifact": entity_summary(a), "quant_format": fmt or None, "file_size_gb": size, "weights_source": "observed" if size else "estimated", "fit": af}) | |
| 497 | + compatible.sort(key=lambda x: (not (x["fit"] or {}).get("fits", False), x["file_size_gb"] or 1e9)) | |
| 498 | + items.append({"model": entity_summary(r), "fit": f, "artifacts": compatible[:8], "artifact_count": len(art_by.get(r["id"], []))}) | |
| 499 | + fits = [i for i in items if i["fit"]["fits"]] | |
| 500 | + fits.sort(key=lambda i: -(i["fit"].get("parameter_count") or 0)) | |
| 501 | + return {"inputs": {"memory_gb": memory_gb, "gpu_count": gpu_count, "total_memory_gb": memory_gb * gpu_count, "quant": q, "context": context, "batch": batch, "platform": platform, "use_case": use_case}, | |
| 502 | + "estimated": True, "assumptions": hf.ASSUMPTIONS, "counts": {"fits": len(fits), "evaluated": len(items)}, "items": fits[:limit], | |
| 503 | + "note": "Every figure is an ESTIMATE: weights = params × bytes/param (× 1.15 overhead) unless an artifact's observed file size is available; KV cache uses architecture metadata when known, " | |
| 504 | + + ("else 0.5 GB per 8K tokens × batch. Multi-GPU sums device memory and ignores interconnect." if gpu_count > 1 else "else 0.5 GB per 8K tokens × batch.")} | |
| 505 | + | |
| 506 | + | |
| 507 | +# ------------------------------------------------------------------------------------------------------------------ /time-machine | |
| 508 | + | |
| 509 | + | |
| 510 | +@router.get("/time-machine") | |
| 511 | +@cached(300) | |
| 512 | +async def time_machine(request: Request, date: str = Query(..., description="YYYY-MM-DD"), scope: str = Query("models", pattern="^(models|prices|benchmarks|hardware|all)$"), | |
| 513 | + limit: int = Query(50, ge=1, le=300)) -> dict[str, Any]: | |
| 514 | + d = parse_date(date, "date") | |
| 515 | + assert d is not None | |
| 516 | + at = datetime.combine(d, dtime.max, UTC) | |
| 517 | + out: dict[str, Any] = {"date": d.isoformat(), "scope": scope} | |
| 518 | + async with connection() as conn: | |
| 519 | + first = await fetch_val(conn, "select min(first_seen_at) from entities") | |
| 520 | + reconstructed = first is not None and at < first | |
| 521 | + out["first_entity_at"] = first | |
| 522 | + out["reconstructed"] = bool(reconstructed) | |
| 523 | + out["note"] = (f"AI Atlas observation history starts at {first.isoformat() if first else 'n/a'}. " + | |
| 524 | + ("This date is earlier: the state is RECONSTRUCTED from claims with effective dates and from release dates — not from direct observation." if reconstructed | |
| 525 | + else "The state is taken from claims, prices and results as they were known at the end of that UTC day.")) | |
| 526 | + if scope in ("models", "all"): | |
| 527 | + rows = await fetch_all(conn, f"""select {ENTITY_COLS}, (e.first_seen_at <= :at) as observed_then from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null | |
| 528 | + and (e.first_seen_at <= :at or (e.attributes->>'release_date' ~ '^\\d{{4}}' and left(e.attributes->>'release_date', 10) <= :d)) | |
| 529 | + order by e.attributes->>'release_date' desc nulls last, e.canonical_name limit :lim""", at=at, d=d.isoformat(), lim=limit) | |
| 530 | + ids = [r["id"] for r in rows] | |
| 531 | + claims = await fetch_all(conn, f"""select distinct on (c.entity_id, c.property) c.entity_id, {CLAIM_COLS} from {CLAIM_FROM} | |
| 532 | + where c.entity_id = any(cast(:ids as text[])) and c.status <> 'retracted' and coalesce(c.effective_at, c.valid_from) <= :at and (c.valid_to is null or c.valid_to > :at) | |
| 533 | + order by c.entity_id, c.property, c.tier, c.valid_from desc""", ids=ids, at=at) if ids else [] | |
| 534 | + by: dict[str, dict[str, Any]] = defaultdict(dict) | |
| 535 | + for c in claims: | |
| 536 | + by[c["entity_id"]][c["property"]] = c["value"] | |
| 537 | + total = await fetch_val(conn, """select count(*) from entities e where e.entity_type = 'model' and e.merged_into is null | |
| 538 | + and (e.first_seen_at <= :at or (e.attributes->>'release_date' ~ '^\\d{4}' and left(e.attributes->>'release_date', 10) <= :d))""", at=at, d=d.isoformat()) | |
| 539 | + out["models"] = {"items": [{"model": entity_summary(r), "attributes_as_of": by.get(r["id"], {}), "observed_then": bool(r["observed_then"]), | |
| 540 | + "reconstructed": not r["observed_then"]} for r in rows], "total": int(total or 0), "limit": limit} | |
| 541 | + if scope in ("prices", "all"): | |
| 542 | + prs = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.valid_from <= :at and (p.valid_to is null or p.valid_to > :at) order by p.output_per_mtok nulls last limit :lim", at=at, lim=limit) | |
| 543 | + n = await fetch_val(conn, "select count(*) from prices p where p.valid_from <= :at and (p.valid_to is null or p.valid_to > :at)", at=at) | |
| 544 | + out["prices"] = {"items": [deployment_row(p) for p in prs], "total": int(n or 0), "note": "offers whose validity interval covers the date (price rows are append-only)"} | |
| 545 | + if scope in ("benchmarks", "all"): | |
| 546 | + rows_r = await load_results(conn, current_only=False) | |
| 547 | + meta = await benchmark_meta(conn) | |
| 548 | + by_b: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 549 | + for r in rows_r: | |
| 550 | + by_b[r["benchmark_id"]].append(r) | |
| 551 | + leaders = [] | |
| 552 | + for bid, brows in by_b.items(): | |
| 553 | + la = leader_at(brows, at, meta.get(bid, {}).get("attributes")) | |
| 554 | + if la: | |
| 555 | + leaders.append({"benchmark": {"id": bid, "slug": meta.get(bid, {}).get("slug"), "name": meta.get(bid, {}).get("name")}, "leader": la}) | |
| 556 | + out["benchmarks"] = {"leaders": sorted(leaders, key=lambda x: x["benchmark"]["name"] or ""), "note": "leaders from results observed by the date (evaluation dates are not used: a result is known only once observed)"} | |
| 557 | + if scope in ("hardware", "all"): | |
| 558 | + hw = await fetch_all(conn, f"""select {ENTITY_COLS}, (e.first_seen_at <= :at) as observed_then from {ENTITY_FROM} where e.entity_type = 'hardware' and e.merged_into is null | |
| 559 | + and (e.first_seen_at <= :at or (e.attributes->>'release_date' ~ '^\\d{{4}}' and left(e.attributes->>'release_date', 7) <= :d)) | |
| 560 | + order by e.attributes->>'release_date' desc nulls last limit :lim""", at=at, d=d.isoformat()[:7], lim=limit) | |
| 561 | + out["hardware"] = {"items": [{"hardware": entity_summary(r), "reconstructed": not r["observed_then"]} for r in hw]} | |
| 562 | + return out | |
| 563 | + | |
| 564 | + | |
| 565 | +__all__ = ["PARETO_X", "router"] | |
modified
src/aiatlas/api/routers/misc.py
+127 −9
@@ -1,4 +1,4 @@ | ||
| 1 | −"""/methodology · /trending · POST /views · /sitemap · /api-keys/me.""" | |
| 1 | +"""/methodology · /trending · POST /views · /sitemap · /api-keys/me · /licenses.""" | |
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | |
| 4 | 4 | import hashlib |
@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field | ||
| 10 | 10 | |
| 11 | 11 | from aiatlas.api.common import ( |
| 12 | 12 | ENTITY_COLS, |
| 13 | + ENTITY_FROM, | |
| 13 | 14 | EVENT_TYPE_LABELS, |
| 14 | 15 | STATUS_VOCAB, |
| 15 | 16 | ApiError, |
@@ -21,7 +22,15 @@ from aiatlas.api.common import ( | ||
| 21 | 22 | ) |
| 22 | 23 | from aiatlas.db import connection, execute, fetch_all, fetch_one, transaction |
| 23 | 24 | from aiatlas.ids import ENTITY_TYPES |
| 25 | +from aiatlas.ontology import anomalies as anomaly_checks | |
| 26 | +from aiatlas.ontology.benchmarks import CONDITION_KEYS, IGNORED_KEYS, TASK_KEYS, TRUST_LABELS, TRUST_LEVELS | |
| 27 | +from aiatlas.ontology.licenses import CATEGORIES, LICENSES, normalize_license | |
| 28 | +from aiatlas.ontology.openness import OPENNESS_CATEGORIES, OPENNESS_DEFINITIONS, OPENNESS_DIMENSIONS, OPENNESS_LABELS | |
| 29 | +from aiatlas.services import hardware_fit as hf | |
| 30 | +from aiatlas.services.finder import RULES as FINDER_RULES | |
| 31 | +from aiatlas.services.frontier import FRONTIER_METHODOLOGY | |
| 24 | 32 | from aiatlas.services.quality import EXPECTED_FIELDS, QUALITY_VERSION |
| 33 | +from aiatlas.services.stats import DEFINITIONS as COUNTER_DEFINITIONS | |
| 25 | 34 | |
| 26 | 35 | router = APIRouter(prefix="/api/v1", tags=["misc"]) |
| 27 | 36 | |
@@ -43,6 +52,42 @@ EXTRACTORS = [ | ||
| 43 | 52 | {"key": "curated", "description": "Hand-maintained registries shipped with the code (organizations, providers, benchmarks, hardware)."}, |
| 44 | 53 | {"key": "llm", "description": "Local LLM extraction validated against a strict JSON schema; medium/low confidence, never overrides deterministic tier-1 claims."}, |
| 45 | 54 | ] |
| 55 | +COMPARABILITY_RULES = { | |
| 56 | + "comparable": "Same benchmark variant, same canonical metric, same task-defining configuration (variant, evaluator/harness, shots, pass regime, scaffold…) and same conditions.", | |
| 57 | + "partially-comparable": "Same task, but conditions differ (reasoning effort, thinking budget, temperature, judge, tool use, max tokens…).", | |
| 58 | + "not-comparable": "Different benchmark variant, different metric, or a task-defining configuration key differs.", | |
| 59 | + "task_keys": list(TASK_KEYS), "condition_keys": list(CONDITION_KEYS), "ignored_keys": sorted(IGNORED_KEYS), | |
| 60 | + "group": "A comparability group is (benchmark, canonical metric, config_key) where config_key = sha1 of the task keys + metric (12 hex chars).", | |
| 61 | + "leaderboard": "One row per canonical model: its best current row inside the chosen group; effort variants folded into a model share its rows.", | |
| 62 | +} | |
| 63 | +ANOMALY_CHECKS = [ | |
| 64 | + {"check": "params_too_large", "severity": "critical", "description": f"parameter_count above {anomaly_checks.MAX_PARAMS:.0e}"}, | |
| 65 | + {"check": "params_too_small", "severity": "warning", "description": "parameter_count below 100K"}, | |
| 66 | + {"check": "active_gt_total", "severity": "critical", "description": "active parameters exceed total parameters"}, | |
| 67 | + {"check": "context_too_large", "severity": "critical", "description": "context_length above 100M tokens"}, | |
| 68 | + {"check": "context_too_small", "severity": "warning", "description": "context_length below 256 tokens"}, | |
| 69 | + {"check": "max_output_gt_context", "severity": "warning", "description": "max_output_tokens exceeds context_length"}, | |
| 70 | + {"check": "release_in_future", "severity": "critical", "description": "release_date after today"}, | |
| 71 | + {"check": "release_too_old", "severity": "warning", "description": "release_date before 2010"}, | |
| 72 | + {"check": "deprecated_before_release", "severity": "critical", "description": "deprecation_date before release_date"}, | |
| 73 | + {"check": "retired_before_release", "severity": "critical", "description": "retirement_date before release_date"}, | |
| 74 | + {"check": "retired_before_deprecated", "severity": "warning", "description": "retirement_date before deprecation_date"}, | |
| 75 | + {"check": "cutoff_after_release", "severity": "warning", "description": "knowledge_cutoff after release_date"}, | |
| 76 | + {"check": "deprecated_future_release", "severity": "critical", "description": "deprecated/retired status with a future release date"}, | |
| 77 | + {"check": "open_without_weights_url", "severity": "info", "description": "labelled open but no weights location recorded"}, | |
| 78 | + {"check": "negative_price", "severity": "critical", "description": "a price field is negative"}, | |
| 79 | + {"check": "price_too_high", "severity": "warning", "description": f"a price exceeds ${anomaly_checks.MAX_PRICE_PER_MTOK:g} per 1M tokens"}, | |
| 80 | + {"check": "zero_output_price", "severity": "warning", "description": "output price 0 while input is positive and the offer is not free"}, | |
| 81 | + {"check": "input_gt_output_price", "severity": "info", "description": "input price more than 4× the output price"}, | |
| 82 | + {"check": "cached_gt_input_price", "severity": "warning", "description": "cached input price above the input price"}, | |
| 83 | + {"check": "price_jump_100x", "severity": "critical", "description": "a price moved by more than 100× between two observations"}, | |
| 84 | + {"check": "score_above_max", "severity": "critical", "description": "benchmark score above the metric maximum"}, | |
| 85 | + {"check": "score_below_min", "severity": "critical", "description": "benchmark score below the metric minimum"}, | |
| 86 | + {"check": "evaluated_before_release", "severity": "warning", "description": "result evaluated more than 45 days before the model's release"}, | |
| 87 | + {"check": "memory_implausible", "severity": "critical", "description": "hardware memory_gb ≤ 0 or above 100 000"}, | |
| 88 | + {"check": "bandwidth_implausible", "severity": "warning", "description": "memory bandwidth ≤ 0 or above 100 000 GB/s"}, | |
| 89 | + {"check": "tdp_implausible", "severity": "warning", "description": "TDP ≤ 0 or above 200 000 W"}, | |
| 90 | +] | |
| 46 | 91 | |
| 47 | 92 | |
| 48 | 93 | @router.get("/methodology") |
@@ -57,21 +102,91 @@ async def methodology(request: Request) -> dict[str, Any]: | ||
| 57 | 102 | for t, (lbl, imp) in EVENT_TYPE_LABELS.items() if t not in seen] |
| 58 | 103 | return {"metrics": metrics, "quality_version": QUALITY_VERSION, "expected_fields": EXPECTED_FIELDS, "confidence_levels": CONFIDENCE_LEVELS, "tiers": TIERS, |
| 59 | 104 | "event_types": types, "status_vocabulary": list(STATUS_VOCAB), "extractors": EXTRACTORS, |
| 105 | + "openness": {"categories": list(OPENNESS_CATEGORIES), "labels": OPENNESS_LABELS, "definitions": OPENNESS_DEFINITIONS, "dimensions": list(OPENNESS_DIMENSIONS), | |
| 106 | + "note": "Categories are derived from measurable dimensions and the licence ontology; a custom community licence is never 'open-source'."}, | |
| 107 | + "trust_levels": [{"key": k, "label": TRUST_LABELS[k]} for k in TRUST_LEVELS], | |
| 108 | + "comparability": COMPARABILITY_RULES, | |
| 109 | + "counters": COUNTER_DEFINITIONS, | |
| 110 | + "anomaly_checks": ANOMALY_CHECKS, | |
| 111 | + "event_semantics": {"occurred_at": "coalesce(effective_at, observed_at) — when the change happened (effective date when a source states it)", | |
| 112 | + "observed_at": "when AI Atlas first saw the change", "recorded_at": "when the row was written", | |
| 113 | + "is_backfill": "true for history imported when a source is first crawled (never shown as 'today' in feeds)", | |
| 114 | + "group_key": "one release / announcement seen through several documents shares a group_key"}, | |
| 115 | + "hardware_fit": {"assumptions": hf.ASSUMPTIONS, "bytes_per_param": hf.BYTES_PER_PARAM, "reserved_gb": hf.RESERVED_GB}, | |
| 116 | + "frontier": FRONTIER_METHODOLOGY, | |
| 117 | + "find_a_model": FINDER_RULES, | |
| 118 | + "licence_categories": list(CATEGORIES), | |
| 60 | 119 | "principles": ["Never fabricate: missing data is reported as unavailable.", "Every fact carries provenance (source, snapshot, URL, tier, confidence, extractor).", |
| 61 | 120 | "History is append-only: claims, prices and benchmark results are never overwritten.", |
| 62 | − "Conflicts between sources are stored side by side and flagged for review.", "Live counters and feeds are computed from the database."]} | |
| 121 | + "Conflicts between sources are stored side by side and flagged for review.", "Live counters and feeds are computed from the database.", | |
| 122 | + "No composite 'best model' score: rankings are per benchmark comparability group; finders return the observed dimensions."]} | |
| 63 | 123 | |
| 64 | 124 | |
| 65 | 125 | @router.get("/trending") |
| 66 | 126 | @cached(300) |
| 67 | −async def trending(request: Request, days: int = Query(7, ge=1, le=90), limit: int = Query(12, ge=1, le=60), type: str | None = Query(None, alias="type")) -> dict[str, Any]: | |
| 127 | +async def trending(request: Request, days: int = Query(7, ge=1, le=90), limit: int = Query(12, ge=1, le=60), type: str | None = Query(None, alias="type"), | |
| 128 | + kind: str = Query("views", pattern="^(views|most_changed|new_listings|new_results)$")) -> dict[str, Any]: | |
| 129 | + """`kind=views` (page views, v1) · `most_changed` (events per entity) · `new_listings` (PROVIDER_LISTED) · `new_results` (benchmark result events) — separate lists, never merged.""" | |
| 130 | + async with connection() as conn: | |
| 131 | + if kind == "views": | |
| 132 | + rows = await fetch_all(conn, f""" | |
| 133 | + with v as (select split_part(regexp_replace(path, '[?#].*$', ''), '/', 3) as slug, sum(views) as views from page_views | |
| 134 | + where day >= ((now() at time zone 'UTC') - make_interval(days => :d))::date and path ~ '^/[a-z-]+/[^/?#]+' group by 1) | |
| 135 | + select v.views as n, {ENTITY_COLS} from v join entities e on e.slug = v.slug left join entities eo on eo.id = e.organization_id | |
| 136 | + where e.merged_into is null {"and e.entity_type = :t" if type else ""} order by v.views desc, e.updated_at desc limit :lim""", d=days, lim=limit, t=type) | |
| 137 | + items = [{**(entity_summary(r) or {}), "views": int(r["n"] or 0)} for r in rows] | |
| 138 | + definition = "Page views recorded by the site beacon in the window." | |
| 139 | + else: | |
| 140 | + cond = {"most_changed": "ev.event_type <> 'DOCUMENT_CHANGED'", "new_listings": "ev.event_type in ('PROVIDER_LISTED','PROVIDER_DELISTED')", | |
| 141 | + "new_results": "ev.event_type in ('BENCHMARK_RESULT','BENCHMARK_UPDATED')"}[kind] | |
| 142 | + rows = await fetch_all(conn, f""" | |
| 143 | + with v as (select ev.entity_id, count(*) as n, max(ev.occurred_at) as last_at from change_events ev | |
| 144 | + where ev.is_backfill = false and ev.occurred_at > now() - make_interval(days => :d) and {cond} group by 1) | |
| 145 | + select v.n, v.last_at, {ENTITY_COLS} from v join entities e on e.id = v.entity_id left join entities eo on eo.id = e.organization_id | |
| 146 | + where e.merged_into is null {"and e.entity_type = :t" if type else ""} order by v.n desc, v.last_at desc limit :lim""", d=days, lim=limit, t=type) | |
| 147 | + items = [{**(entity_summary(r) or {}), "events": int(r["n"] or 0), "last_event_at": r["last_at"]} for r in rows] | |
| 148 | + definition = {"most_changed": "Entities with the most non-backfill events (any type except source-document changes) that occurred in the window.", | |
| 149 | + "new_listings": "Entities with the most provider listing / delisting events in the window.", | |
| 150 | + "new_results": "Entities with the most new or updated benchmark result events in the window."}[kind] | |
| 151 | + return {"days": days, "kind": kind, "items": items, "definition": definition} | |
| 152 | + | |
| 153 | + | |
| 154 | +@router.get("/licenses") | |
| 155 | +@cached(600) | |
| 156 | +async def licenses(request: Request) -> dict[str, Any]: | |
| 157 | + """Licence ontology + how many canonical models use each key (canonical `license_key` or a raw label the ontology maps to it).""" | |
| 68 | 158 | async with connection() as conn: |
| 69 | − rows = await fetch_all(conn, f""" | |
| 70 | − with v as (select split_part(regexp_replace(path, '[?#].*$', ''), '/', 3) as slug, sum(views) as views from page_views | |
| 71 | − where day >= ((now() at time zone 'UTC') - make_interval(days => :d))::date and path ~ '^/[a-z-]+/[^/?#]+' group by 1) | |
| 72 | − select v.views, {ENTITY_COLS} from v join entities e on e.slug = v.slug left join entities eo on eo.id = e.organization_id | |
| 73 | − where e.merged_into is null {"and e.entity_type = :t" if type else ""} order by v.views desc, e.updated_at desc limit :lim""", d=days, lim=limit, t=type) | |
| 74 | − return {"days": days, "items": [{**(entity_summary(r) or {}), "views": int(r["views"] or 0)} for r in rows]} | |
| 159 | + rows = await fetch_all(conn, """select coalesce(e.attributes->>'license_key', e.attributes->>'license') as raw, count(*) as n from entities e | |
| 160 | + where e.entity_type = 'model' and e.merged_into is null and (e.attributes ? 'license' or e.attributes ? 'license_key') group by 1""") | |
| 161 | + counts: dict[str, int] = {} | |
| 162 | + unclassified: dict[str, int] = {} | |
| 163 | + for r in rows: | |
| 164 | + key = r["raw"] if r["raw"] in LICENSES else normalize_license(r["raw"]) | |
| 165 | + if key: | |
| 166 | + counts[key] = counts.get(key, 0) + int(r["n"]) | |
| 167 | + elif r["raw"]: | |
| 168 | + unclassified[r["raw"]] = unclassified.get(r["raw"], 0) + int(r["n"]) | |
| 169 | + items = [{**info.as_dict(), "aliases": list(info.aliases), "models": counts.get(key, 0)} for key, info in LICENSES.items()] | |
| 170 | + items.sort(key=lambda x: (-x["models"], x["label"])) | |
| 171 | + return {"items": items, "total": len(items), "categories": list(CATEGORIES), "unclassified": [{"raw": k, "models": v} for k, v in sorted(unclassified.items(), key=lambda kv: -kv[1])], | |
| 172 | + "note": "Permissions are read from the licence text (null = the text is ambiguous). Counts cover canonical models only."} | |
| 173 | + | |
| 174 | + | |
| 175 | +@router.get("/licenses/{key}") | |
| 176 | +@cached(600) | |
| 177 | +async def license_detail(request: Request, key: str, limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: | |
| 178 | + canon = key if key in LICENSES else normalize_license(key) | |
| 179 | + info = LICENSES.get(canon) if canon else None | |
| 180 | + if not info: | |
| 181 | + raise ApiError(404, f"unknown licence {key!r}") | |
| 182 | + raw = sorted({info.key.lower(), *(a.lower() for a in info.aliases), *([info.spdx.lower()] if info.spdx else [])}) | |
| 183 | + async with connection() as conn: | |
| 184 | + rows = await fetch_all(conn, f"""select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null | |
| 185 | + and (e.attributes->>'license_key' = :k or lower(e.attributes->>'license') = any(cast(:raw as text[]))) | |
| 186 | + order by e.attributes->>'release_date' desc nulls last, e.canonical_name limit :lim""", k=info.key, raw=raw, lim=limit) | |
| 187 | + total = await fetch_one(conn, """select count(*) as n from entities e where e.entity_type = 'model' and e.merged_into is null | |
| 188 | + and (e.attributes->>'license_key' = :k or lower(e.attributes->>'license') = any(cast(:raw as text[])))""", k=info.key, raw=raw) | |
| 189 | + return {**info.as_dict(), "aliases": list(info.aliases), "models": {"items": [entity_summary(r) for r in rows], "total": int((total or {}).get("n") or 0), "limit": limit, "offset": 0}} | |
| 75 | 190 | |
| 76 | 191 | |
| 77 | 192 | class ViewBeacon(BaseModel): |
@@ -113,3 +228,6 @@ async def api_key_me(request: Request) -> dict[str, Any]: | ||
| 113 | 228 | if not row: |
| 114 | 229 | raise ApiError(401, "unknown or disabled API key") |
| 115 | 230 | return row |
| 231 | + | |
| 232 | + | |
| 233 | +__all__ = ["ANOMALY_CHECKS", "COMPARABILITY_RULES", "router"] | |
modified
src/aiatlas/api/routers/models.py
+130 −23
@@ -1,4 +1,7 @@ | ||
| 1 | −"""/models listing (filters on `entities.attributes`, facets) and /models/{slug} alias.""" | |
| 1 | +"""/models listing (canonical universe, filters on `entities.attributes`, facets), /models/{slug} alias, /models/{a}/diff/{b}. | |
| 2 | + | |
| 3 | +API 1.1: the default universe is CANONICAL MODELS (`entity_type = 'model' and merged_into is null`). `include=artifacts` adds | |
| 4 | +checkpoints / quantisations / conversions as rows with `entity_type: 'artifact'` and a `canonical` summary.""" | |
| 2 | 5 | from __future__ import annotations |
| 3 | 6 | |
| 4 | 7 | import asyncio |
@@ -9,18 +12,24 @@ from fastapi import APIRouter, Query, Request | ||
| 9 | 12 | from aiatlas.api.common import ( |
| 10 | 13 | ENTITY_COLS, |
| 11 | 14 | ENTITY_FROM, |
| 15 | + MODEL_OR_ARTIFACT_UNIVERSE, | |
| 16 | + MODEL_UNIVERSE, | |
| 12 | 17 | PAGINATION, |
| 13 | 18 | ApiError, |
| 14 | 19 | Pagination, |
| 20 | + attr_num, | |
| 15 | 21 | cached, |
| 22 | + entity_cols, | |
| 16 | 23 | entity_summary, |
| 17 | 24 | flip_order, |
| 18 | 25 | normalize, |
| 19 | 26 | num_expr, |
| 27 | + openness_values, | |
| 20 | 28 | page, |
| 21 | 29 | ) |
| 22 | 30 | from aiatlas.api.routers.entities import detail_for_type |
| 23 | 31 | from aiatlas.db import connection, fetch_all, fetch_val |
| 32 | +from aiatlas.ontology.licenses import LICENSES, normalize_license | |
| 24 | 33 | from aiatlas.services import cache |
| 25 | 34 | |
| 26 | 35 | router = APIRouter(prefix="/api/v1/models", tags=["models"]) |
@@ -32,13 +41,32 @@ SORTS = { | ||
| 32 | 41 | "updated": "e.updated_at desc", "name": "e.canonical_name asc", "params": f"{PARAMS} desc nulls last", "context": f"{CONTEXT} desc nulls last", |
| 33 | 42 | "release": "e.attributes->>'release_date' desc nulls last", "quality": "coalesce((e.quality->>'score')::float, 0) desc", "downloads": f"{DOWNLOADS} desc nulls last", |
| 34 | 43 | "first_seen": "e.first_seen_at desc", |
| 44 | + "cheapest": "(select min(p.output_per_mtok) from prices p where p.model_id = e.id and p.valid_to is null and p.output_per_mtok > 0) asc nulls last", | |
| 35 | 45 | } |
| 46 | +FAMILY_JOIN = "left join entities fam on fam.id = e.family_id" | |
| 47 | +CANONICAL_JOIN = "left join entities can on can.id = e.canonical_id left join entities cano on cano.id = can.organization_id" | |
| 48 | + | |
| 49 | + | |
| 50 | +def license_match_sql(param: str = "license") -> str: | |
| 51 | + """Match a canonical licence key (`attributes.license_key`) OR any raw label the ontology maps to it OR a plain ilike on the raw label.""" | |
| 52 | + return f"(e.attributes->>'license_key' = :{param} or lower(e.attributes->>'license') = any(cast(:{param}_raw as text[])) or e.attributes->>'license' ilike :{param})" | |
| 53 | + | |
| 54 | + | |
| 55 | +def license_params(value: str) -> dict[str, Any]: | |
| 56 | + key = value if value in LICENSES else (normalize_license(value) or value) | |
| 57 | + info = LICENSES.get(key) | |
| 58 | + raw = {key.lower(), value.lower()} | |
| 59 | + if info: | |
| 60 | + raw |= {a.lower() for a in info.aliases} | |
| 61 | + if info.spdx: | |
| 62 | + raw.add(info.spdx.lower()) | |
| 63 | + return {"license": key, "license_raw": sorted(raw)} | |
| 36 | 64 | |
| 37 | 65 | |
| 38 | 66 | def model_filters(*, q: str | None, org: str | None, family: str | None, openness: str | None, modality: str | None, status: str | None, |
| 39 | 67 | min_params: float | None, max_params: float | None, min_context: int | None, year_from: int | None, year_to: int | None, |
| 40 | − license: str | None) -> tuple[list[str], dict[str, Any]]: | |
| 41 | − where = ["e.entity_type = 'model'", "e.merged_into is null"] | |
| 68 | + license: str | None, include_artifacts: bool = False, reasoning: bool | None = None, trust: str | None = None) -> tuple[list[str], dict[str, Any]]: | |
| 69 | + where = [MODEL_OR_ARTIFACT_UNIVERSE if include_artifacts else MODEL_UNIVERSE] | |
| 42 | 70 | p: dict[str, Any] = {} |
| 43 | 71 | if q: |
| 44 | 72 | where.append("(e.canonical_name ilike :qlike or e.search @@ plainto_tsquery('simple', :q) or exists (select 1 from entity_aliases a where a.entity_id = e.id and a.alias ilike :qlike))") |
@@ -47,14 +75,12 @@ def model_filters(*, q: str | None, org: str | None, family: str | None, opennes | ||
| 47 | 75 | where.append("(eo.slug = :org or eo.id = :org or eo.canonical_name ilike :org)") |
| 48 | 76 | p["org"] = org |
| 49 | 77 | if family: |
| 50 | − where.append("e.attributes->>'family' ilike :family") | |
| 78 | + # family slug / id (model_family entity via family_id) — falls back to the legacy `attributes.family` label | |
| 79 | + where.append("(fam.slug = :family or fam.id = :family or fam.canonical_name ilike :family or e.attributes->>'family' ilike :family)") | |
| 51 | 80 | p["family"] = family |
| 52 | 81 | if openness: |
| 53 | − vals = [v.strip() for v in openness.split(",") if v.strip()] | |
| 54 | − if "open" in vals: | |
| 55 | − vals += ["open-weights", "open-source"] | |
| 56 | 82 | where.append("e.attributes->>'openness' = any(cast(:openness as text[]))") |
| 57 | − p["openness"] = vals | |
| 83 | + p["openness"] = openness_values(openness) | |
| 58 | 84 | if modality: |
| 59 | 85 | where.append("(e.attributes->'modalities' ? :modality or e.attributes->'modalities_input' ? :modality or e.attributes->'modalities_output' ? :modality)") |
| 60 | 86 | p["modality"] = modality |
@@ -77,31 +103,61 @@ def model_filters(*, q: str | None, org: str | None, family: str | None, opennes | ||
| 77 | 103 | where.append("left(e.attributes->>'release_date', 4) <= :yt") |
| 78 | 104 | p["yt"] = str(year_to) |
| 79 | 105 | if license: |
| 80 | − where.append("e.attributes->>'license' ilike :license") | |
| 81 | − p["license"] = license | |
| 106 | + where.append(license_match_sql()) | |
| 107 | + p.update(license_params(license)) | |
| 108 | + if reasoning is not None: | |
| 109 | + where.append("e.attributes->>'reasoning' = :reasoning") | |
| 110 | + p["reasoning"] = "true" if reasoning else "false" | |
| 111 | + if trust: | |
| 112 | + where.append("e.identity_confidence = any(cast(:trust as text[]))") | |
| 113 | + p["trust"] = [v.strip() for v in trust.split(",") if v.strip()] | |
| 82 | 114 | return where, p |
| 83 | 115 | |
| 84 | 116 | |
| 117 | +def _canonical_licenses(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| 118 | + """Fold raw licence labels into canonical keys (Apache 2.0 / apache-2.0 → Apache-2.0); unknown labels are kept raw under `raw: true`.""" | |
| 119 | + agg: dict[str, dict[str, Any]] = {} | |
| 120 | + for r in rows: | |
| 121 | + raw = r["value"] | |
| 122 | + if raw in (None, ""): | |
| 123 | + continue | |
| 124 | + key = normalize_license(raw) | |
| 125 | + k = key or str(raw) | |
| 126 | + item = agg.setdefault(k, {"value": k, "label": LICENSES[key].label if key else str(raw), "category": LICENSES[key].category if key else "unknown", | |
| 127 | + "count": 0, "raw_labels": [], **({} if key else {"raw": True})}) | |
| 128 | + item["count"] += int(r["count"]) | |
| 129 | + if str(raw) != k: | |
| 130 | + item["raw_labels"].append(str(raw)) | |
| 131 | + return sorted(agg.values(), key=lambda x: (-x["count"], x["value"])) | |
| 132 | + | |
| 133 | + | |
| 85 | 134 | async def model_facets(where_sql: str, params: dict[str, Any]) -> dict[str, Any]: |
| 86 | 135 | async def run(sql: str) -> list[dict[str, Any]]: |
| 87 | 136 | async with connection() as conn: |
| 88 | 137 | return await fetch_all(conn, sql, **params) |
| 89 | 138 | |
| 90 | − base = f"from {ENTITY_FROM} where {where_sql}" | |
| 91 | − mods_from = (f"from {ENTITY_FROM} cross join lateral jsonb_array_elements_text(case when jsonb_typeof(e.attributes->'modalities') = 'array' " | |
| 139 | + base = f"from {ENTITY_FROM} {FAMILY_JOIN} where {where_sql}" | |
| 140 | + mods_from = (f"from {ENTITY_FROM} {FAMILY_JOIN} cross join lateral jsonb_array_elements_text(case when jsonb_typeof(e.attributes->'modalities') = 'array' " | |
| 92 | 141 | f"then e.attributes->'modalities' else '[]'::jsonb end) m where {where_sql}") |
| 93 | − orgs, openness, mods, fams, years, lics, status = await asyncio.gather( | |
| 142 | + orgs, openness, mods, fams, years, lics, status, trust = await asyncio.gather( | |
| 94 | 143 | run(f"select eo.slug, eo.canonical_name as name, count(*) as count {base} and eo.id is not null group by 1, 2 order by 3 desc, 2 limit 60"), |
| 95 | 144 | run(f"select e.attributes->>'openness' as value, count(*) as count {base} and e.attributes ? 'openness' group by 1 order by 2 desc"), |
| 96 | 145 | run(f"select m.value, count(*) as count {mods_from} group by 1 order by 2 desc limit 30"), |
| 97 | − run(f"select e.attributes->>'family' as value, count(*) as count {base} and e.attributes ? 'family' group by 1 order by 2 desc, 1 limit 60"), | |
| 146 | + run(f"select coalesce(fam.slug, e.attributes->>'family') as value, coalesce(fam.canonical_name, e.attributes->>'family') as label, fam.id is not null as canonical, " | |
| 147 | + f"count(*) as count {base} and (fam.id is not null or e.attributes ? 'family') group by 1, 2, 3 order by 4 desc, 2 limit 60"), | |
| 98 | 148 | run(f"select left(e.attributes->>'release_date', 4) as value, count(*) as count {base} and e.attributes ? 'release_date' group by 1 order by 1 desc limit 30"), |
| 99 | − run(f"select e.attributes->>'license' as value, count(*) as count {base} and e.attributes ? 'license' group by 1 order by 2 desc, 1 limit 40"), | |
| 149 | + run(f"select coalesce(e.attributes->>'license_key', e.attributes->>'license') as value, count(*) as count {base} and (e.attributes ? 'license' or e.attributes ? 'license_key') group by 1 order by 2 desc, 1 limit 80"), | |
| 100 | 150 | run(f"select e.status as value, count(*) as count {base} group by 1 order by 2 desc"), |
| 151 | + run(f"select e.identity_confidence as value, count(*) as count {base} group by 1 order by 2 desc"), | |
| 101 | 152 | ) |
| 102 | 153 | conv = lambda rows: [{"value": r["value"], "count": int(r["count"])} for r in rows if r["value"] not in (None, "")] |
| 103 | 154 | return {"organizations": [{"slug": r["slug"], "name": r["name"], "count": int(r["count"])} for r in orgs], "openness": conv(openness), "modalities": conv(mods), |
| 104 | − "families": conv(fams), "years": conv(years), "licenses": conv(lics), "status": conv(status)} | |
| 155 | + "families": [{"value": r["value"], "label": r["label"], "canonical": bool(r["canonical"]), "count": int(r["count"])} for r in fams if r["value"]], | |
| 156 | + "years": conv(years), "licenses": _canonical_licenses(lics), "status": conv(status), | |
| 157 | + "trust": [{**x, "label": {"high": "Identity confirmed", "medium": "Identity probable", "low": "Identity uncertain"}.get(x["value"], x["value"])} for x in conv(trust)], | |
| 158 | + "definitions": {"trust": "identity_confidence of the row: how sure AI Atlas is that this entry is one real model release (high | medium | low)", | |
| 159 | + "licenses": "canonical licence keys from the ontology; raw labels that could not be classified are flagged raw: true", | |
| 160 | + "families": "model_family entities (canonical: true) or legacy attribute labels (canonical: false)"}} | |
| 105 | 161 | |
| 106 | 162 | |
| 107 | 163 | @router.get("") |
@@ -110,25 +166,41 @@ async def list_models(request: Request, p: Pagination = PAGINATION, q: str | Non | ||
| 110 | 166 | openness: str | None = None, modality: str | None = None, status: str | None = None, min_params: float | None = Query(None, ge=0), |
| 111 | 167 | max_params: float | None = Query(None, ge=0), min_context: int | None = Query(None, ge=0), year_from: int | None = Query(None, ge=1950, le=2100), |
| 112 | 168 | year_to: int | None = Query(None, ge=1950, le=2100), license: str | None = None, sort: str = "updated", order: str = "", |
| 113 | − facets: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | |
| 169 | + facets: int = Query(0, ge=0, le=1), include: str | None = Query(None, description="`artifacts` restores the pre-1.1 universe (models + artifacts)"), | |
| 170 | + reasoning: int | None = Query(None, ge=0, le=1), trust: str | None = Query(None, description="identity_confidence: high,medium,low")) -> dict[str, Any]: | |
| 114 | 171 | if sort not in SORTS: |
| 115 | 172 | raise ApiError(400, f"sort must be one of {', '.join(SORTS)}") |
| 173 | + include_artifacts = "artifacts" in {x.strip() for x in (include or "").split(",")} | |
| 116 | 174 | where, params = model_filters(q=q, org=org, family=family, openness=openness, modality=modality, status=status, min_params=min_params, max_params=max_params, |
| 117 | − min_context=min_context, year_from=year_from, year_to=year_to, license=license) | |
| 175 | + min_context=min_context, year_from=year_from, year_to=year_to, license=license, include_artifacts=include_artifacts, | |
| 176 | + reasoning=None if reasoning is None else bool(reasoning), trust=trust) | |
| 118 | 177 | order_sql = flip_order(SORTS[sort], order) |
| 119 | 178 | where_sql = " and ".join(where) |
| 120 | 179 | async with connection() as conn: |
| 121 | − rows = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where {where_sql} order by {order_sql}, e.id limit :lim offset :off", | |
| 180 | + rows = await fetch_all(conn, f"""select {ENTITY_COLS}, e.family_id, e.canonical_id, e.artifact_kind, e.identity_confidence, | |
| 181 | + fam.slug as family_slug, fam.canonical_name as family_name, {entity_cols("can", "c_")} | |
| 182 | + from {ENTITY_FROM} {FAMILY_JOIN} {CANONICAL_JOIN} where {where_sql} order by {order_sql}, e.id limit :lim offset :off""", | |
| 122 | 183 | lim=p.limit, off=p.offset, **params) |
| 123 | − total = await fetch_val(conn, f"select count(*) from {ENTITY_FROM} where {where_sql}", **params) | |
| 124 | − out = page([entity_summary(r) for r in rows], int(total or 0), p) | |
| 184 | + total = await fetch_val(conn, f"select count(*) from {ENTITY_FROM} {FAMILY_JOIN} where {where_sql}", **params) | |
| 185 | + items = [] | |
| 186 | + for r in rows: | |
| 187 | + s = entity_summary(r) or {} | |
| 188 | + if r.get("family_id"): | |
| 189 | + s["family"] = {"id": r["family_id"], "slug": r["family_slug"], "name": r["family_name"]} | |
| 190 | + if r["entity_type"] == "artifact": | |
| 191 | + s["artifact_kind"] = r.get("artifact_kind") | |
| 192 | + s["canonical"] = entity_summary(r, "c_") | |
| 193 | + s["identity_confidence"] = r.get("identity_confidence") | |
| 194 | + items.append(s) | |
| 195 | + out = page(items, int(total or 0), p) | |
| 196 | + out["universe"] = "models+artifacts" if include_artifacts else "canonical models" | |
| 125 | 197 | if facets: |
| 126 | 198 | out["facets"] = await _facets_cached(where_sql, params) |
| 127 | 199 | return out |
| 128 | 200 | |
| 129 | 201 | |
| 130 | 202 | async def _facets_cached(where_sql: str, params: dict[str, Any]) -> dict[str, Any]: |
| 131 | − key = "facets:models:" + where_sql + ":" + repr(sorted(params.items())) | |
| 203 | + key = "facets:models:v2:" + where_sql + ":" + repr(sorted(params.items())) | |
| 132 | 204 | hit = await cache.cache_get(key) |
| 133 | 205 | if hit is not None: |
| 134 | 206 | return hit |
@@ -140,4 +212,39 @@ async def _facets_cached(where_sql: str, params: dict[str, Any]) -> dict[str, An | ||
| 140 | 212 | @router.get("/{slug}") |
| 141 | 213 | @cached(300) |
| 142 | 214 | async def get_model(request: Request, slug: str) -> dict[str, Any]: |
| 143 | − return await detail_for_type(slug, ("model",)) | |
| 215 | + """Accepts canonical models AND artifacts (an artifact detail carries `canonical` + `artifact_kind`); folded variants follow `merged_into` | |
| 216 | + and report `redirected_from` so the web layer can 301.""" | |
| 217 | + return await detail_for_type(slug, ("model", "artifact")) | |
| 218 | + | |
| 219 | + | |
| 220 | +@router.get("/{a}/diff/{b}") | |
| 221 | +@cached(300) | |
| 222 | +async def model_diff(request: Request, a: str, b: str) -> dict[str, Any]: | |
| 223 | + """Only the dimensions where two models differ, with a delta (numeric % change, list added/removed).""" | |
| 224 | + from aiatlas.api.routers.compare import compare_entities | |
| 225 | + | |
| 226 | + body = await compare_entities([a, b], diff_only=True) | |
| 227 | + items = body["items"] | |
| 228 | + if len(items) != 2: | |
| 229 | + raise ApiError(400, "two models are required") | |
| 230 | + va, vb = items[0]["values"], items[1]["values"] | |
| 231 | + dims = [] | |
| 232 | + for d in body["dimensions"]: | |
| 233 | + x, y = va.get(d["key"]), vb.get(d["key"]) | |
| 234 | + dims.append({**d, "a": x, "b": y, "delta": _delta(x, y, d.get("kind"))}) | |
| 235 | + return {"a": items[0]["entity"], "b": items[1]["entity"], "dimensions": dims, "comparability": body.get("comparability"), | |
| 236 | + "note": "Only dimensions with differing observed values; numeric delta = (b − a) / a; lists show added/removed elements."} | |
| 237 | + | |
| 238 | + | |
| 239 | +def _delta(x: Any, y: Any, kind: str | None) -> dict[str, Any] | None: | |
| 240 | + if isinstance(x, list) or isinstance(y, list): | |
| 241 | + sx, sy = {str(v) for v in (x or [])}, {str(v) for v in (y or [])} | |
| 242 | + return {"added": sorted(sy - sx), "removed": sorted(sx - sy)} | |
| 243 | + try: | |
| 244 | + fx, fy = float(x), float(y) | |
| 245 | + except (TypeError, ValueError): | |
| 246 | + return None | |
| 247 | + return {"absolute": fy - fx, "percent": round((fy - fx) / fx * 100, 2) if fx else None} | |
| 248 | + | |
| 249 | + | |
| 250 | +__all__ = ["CONTEXT", "PARAMS", "attr_num", "license_match_sql", "license_params", "model_filters", "router"] | |
modified
src/aiatlas/api/routers/prices.py
+100 −17
@@ -1,4 +1,7 @@ | ||
| 1 | −"""/prices · /prices/history · /prices/index — append-only pricing table (`valid_to` closes a row).""" | |
| 1 | +"""/prices · /prices/history · /prices/index — append-only pricing table (`valid_to` closes a row). | |
| 2 | + | |
| 3 | +API 1.1: `/prices` gains `family=`, `org=`, `modality=` filters and `sort=cheapest_frontier`; `/prices/index` becomes the AI Price Index | |
| 4 | +(daily medians for all / frontier / open / embedding offers, cheapest frontier offer, current distribution, listings and delistings).""" | |
| 2 | 5 | from __future__ import annotations |
| 3 | 6 | |
| 4 | 7 | from typing import Any |
@@ -8,6 +11,7 @@ from fastapi import APIRouter, Query, Request | ||
| 8 | 11 | from aiatlas.api.common import ( |
| 9 | 12 | EVENT_COLS, |
| 10 | 13 | EVENT_FROM, |
| 14 | + OPEN_CATEGORIES, | |
| 11 | 15 | PAGINATION, |
| 12 | 16 | PRICE_COLS, |
| 13 | 17 | PRICE_FROM, |
@@ -19,17 +23,19 @@ from aiatlas.api.common import ( | ||
| 19 | 23 | price_row, |
| 20 | 24 | resolve_id, |
| 21 | 25 | ) |
| 22 | −from aiatlas.db import connection, fetch_all, fetch_val | |
| 26 | +from aiatlas.db import connection, fetch_all, fetch_one, fetch_val | |
| 27 | +from aiatlas.services.frontier import FRONTIER_METHODOLOGY, frontier_model_ids | |
| 23 | 28 | |
| 24 | 29 | router = APIRouter(prefix="/api/v1/prices", tags=["prices"]) |
| 25 | 30 | SORTS = {"input": "p.input_per_mtok asc nulls last", "output": "p.output_per_mtok asc nulls last", "model": "m.canonical_name asc", "provider": "pv.canonical_name asc", |
| 26 | − "observed": "p.observed_at desc", "valid_from": "p.valid_from desc"} | |
| 31 | + "observed": "p.observed_at desc", "valid_from": "p.valid_from desc", "cheapest_frontier": "p.output_per_mtok asc nulls last"} | |
| 32 | +BUCKETS = [(0, 0.1), (0.1, 0.5), (0.5, 1), (1, 2), (2, 5), (5, 10), (10, 20), (20, 50), (50, None)] | |
| 27 | 33 | |
| 28 | 34 | |
| 29 | 35 | @router.get("") |
| 30 | 36 | @cached(300) |
| 31 | 37 | async def list_prices(request: Request, p: Pagination = PAGINATION, model: str | None = None, provider: str | None = None, sort: str = "model", order: str = "", |
| 32 | − current: int = Query(1, ge=0, le=1)) -> dict[str, Any]: | |
| 38 | + current: int = Query(1, ge=0, le=1), family: str | None = None, org: str | None = None, modality: str | None = None) -> dict[str, Any]: | |
| 33 | 39 | if sort not in SORTS: |
| 34 | 40 | raise ApiError(400, f"sort must be one of {', '.join(SORTS)}") |
| 35 | 41 | order_sql = SORTS[sort] |
@@ -38,7 +44,9 @@ async def list_prices(request: Request, p: Pagination = PAGINATION, model: str | | ||
| 38 | 44 | elif order == "asc" and " desc" in order_sql: |
| 39 | 45 | order_sql = order_sql.replace(" desc", " asc") |
| 40 | 46 | where = ["p.valid_to is null"] if current else ["true"] |
| 47 | + where.append("m.entity_type in ('model','artifact')") | |
| 41 | 48 | params: dict[str, Any] = {} |
| 49 | + frontier_note = None | |
| 42 | 50 | async with connection() as conn: |
| 43 | 51 | if model: |
| 44 | 52 | where.append("p.model_id = :model") |
@@ -46,10 +54,28 @@ async def list_prices(request: Request, p: Pagination = PAGINATION, model: str | | ||
| 46 | 54 | if provider: |
| 47 | 55 | where.append("p.provider_id = :provider") |
| 48 | 56 | params["provider"] = await resolve_id(conn, provider) |
| 57 | + if family: | |
| 58 | + where.append("(exists (select 1 from entities f where f.id = m.family_id and (f.slug = :family or f.id = :family or f.canonical_name ilike :family)) or m.attributes->>'family' ilike :family)") | |
| 59 | + params["family"] = family | |
| 60 | + if org: | |
| 61 | + where.append("(mo.slug = :org or mo.id = :org or mo.canonical_name ilike :org)") | |
| 62 | + params["org"] = org | |
| 63 | + if modality: | |
| 64 | + where.append("(m.attributes->'modalities' ? :modality or m.attributes->'modalities_input' ? :modality or m.attributes->'modalities_output' ? :modality)") | |
| 65 | + params["modality"] = modality | |
| 66 | + if sort == "cheapest_frontier": | |
| 67 | + ids, _ = await frontier_model_ids(conn) | |
| 68 | + where.append("p.model_id = any(cast(:frontier as text[]))") | |
| 69 | + where.append("p.output_per_mtok > 0") | |
| 70 | + params["frontier"] = sorted(ids) | |
| 71 | + frontier_note = FRONTIER_METHODOLOGY | |
| 49 | 72 | where_sql = " and ".join(where) |
| 50 | 73 | rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where {where_sql} order by {order_sql}, p.id limit :lim offset :off", lim=p.limit, off=p.offset, **params) |
| 51 | − total = await fetch_val(conn, f"select count(*) from prices p where {where_sql}", **params) | |
| 52 | − return page([price_row(r) for r in rows], int(total or 0), p) | |
| 74 | + total = await fetch_val(conn, f"select count(*) from {PRICE_FROM} where {where_sql}", **params) | |
| 75 | + out = page([price_row(r) for r in rows], int(total or 0), p) | |
| 76 | + if frontier_note: | |
| 77 | + out["methodology"] = frontier_note | |
| 78 | + return out | |
| 53 | 79 | |
| 54 | 80 | |
| 55 | 81 | @router.get("/history") |
@@ -73,18 +99,75 @@ async def price_history(request: Request, model: str | None = None, provider: st | ||
| 73 | 99 | @router.get("/index") |
| 74 | 100 | @cached(600) |
| 75 | 101 | async def price_index(request: Request, days: int = Query(180, ge=7, le=1825)) -> dict[str, Any]: |
| 102 | + """AI Price Index: daily medians of live USD-per-1M-token prices, by universe (all offers / frontier / open-weight / embeddings).""" | |
| 76 | 103 | async with connection() as conn: |
| 104 | + frontier, composition = await frontier_model_ids(conn) | |
| 105 | + open_ids = [r["id"] for r in await fetch_all(conn, "select id from entities where entity_type = 'model' and merged_into is null and attributes->>'openness' = any(cast(:o as text[]))", o=list(OPEN_CATEGORIES))] | |
| 106 | + emb_ids = [r["id"] for r in await fetch_all(conn, """select id from entities where entity_type = 'model' and merged_into is null and (attributes->'modalities' ? 'embedding' | |
| 107 | + or attributes->'modalities_output' ? 'embedding' or attributes->>'pipeline_tag' ilike '%embedding%')""")] | |
| 77 | 108 | series = await fetch_all(conn, """ |
| 78 | 109 | with days as (select generate_series(((now() at time zone 'UTC') - make_interval(days => :d))::date::timestamp, (now() at time zone 'UTC')::date::timestamp, interval '1 day')::date as day), |
| 79 | − live as (select p.model_id, p.input_per_mtok, p.output_per_mtok, p.valid_from, p.valid_to from prices p | |
| 80 | − where p.input_per_mtok is not null and p.input_per_mtok > 0 and p.valid_from < now()) | |
| 81 | − select d.day, percentile_cont(0.5) within group (order by l.input_per_mtok) as median_input, | |
| 82 | − percentile_cont(0.5) within group (order by l.output_per_mtok) as median_output, | |
| 83 | − min(l.input_per_mtok) as min_input, max(l.input_per_mtok) as max_input, count(distinct l.model_id) as models, count(l.model_id) as offers | |
| 110 | + live as (select p.model_id, p.provider_id, p.input_per_mtok, p.output_per_mtok, p.valid_from, p.valid_to, | |
| 111 | + p.model_id = any(cast(:frontier as text[])) as is_frontier, p.model_id = any(cast(:open as text[])) as is_open, p.model_id = any(cast(:emb as text[])) as is_emb | |
| 112 | + from prices p join entities m on m.id = p.model_id | |
| 113 | + where m.entity_type in ('model','artifact') and m.merged_into is null and ((p.input_per_mtok is not null and p.input_per_mtok > 0) or (p.output_per_mtok is not null and p.output_per_mtok > 0)) and p.valid_from < now()) | |
| 114 | + select d.day, | |
| 115 | + percentile_cont(0.5) within group (order by l.input_per_mtok) filter (where l.input_per_mtok > 0) as median_input, | |
| 116 | + percentile_cont(0.5) within group (order by l.output_per_mtok) filter (where l.output_per_mtok > 0) as median_output, | |
| 117 | + percentile_cont(0.5) within group (order by l.output_per_mtok) filter (where l.output_per_mtok > 0 and l.is_frontier) as median_frontier_output, | |
| 118 | + percentile_cont(0.5) within group (order by l.input_per_mtok) filter (where l.input_per_mtok > 0 and l.is_frontier) as median_frontier_input, | |
| 119 | + percentile_cont(0.5) within group (order by l.output_per_mtok) filter (where l.output_per_mtok > 0 and l.is_open) as median_open_output, | |
| 120 | + percentile_cont(0.5) within group (order by l.input_per_mtok) filter (where l.input_per_mtok > 0 and l.is_emb) as median_embedding_input, | |
| 121 | + min(l.input_per_mtok) filter (where l.input_per_mtok > 0) as min_input, max(l.input_per_mtok) as max_input, | |
| 122 | + min(l.output_per_mtok) filter (where l.output_per_mtok > 0 and l.is_frontier) as min_frontier_output, | |
| 123 | + count(distinct l.model_id) as models, count(l.model_id) as offers, | |
| 124 | + count(distinct l.model_id) filter (where l.is_frontier) as frontier_models, count(l.model_id) filter (where l.is_frontier) as frontier_offers, | |
| 125 | + count(distinct l.model_id) filter (where l.is_open) as open_models, count(distinct l.model_id) filter (where l.is_emb) as embedding_models | |
| 84 | 126 | from days d left join live l on l.valid_from < (d.day + 1)::timestamp at time zone 'UTC' and (l.valid_to is null or l.valid_to >= (d.day + 1)::timestamp at time zone 'UTC') |
| 85 | − group by d.day order by d.day""", d=days) | |
| 86 | − movers = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where ev.category = 'price' and ev.observed_at > now() - make_interval(days => :d) " | |
| 87 | − f"order by ev.importance desc, ev.observed_at desc limit 30", d=days) | |
| 88 | − return {"days": days, "series": [{"day": r["day"].isoformat(), "median_input": r["median_input"], "median_output": r["median_output"], "min_input": r["min_input"], | |
| 89 | − "max_input": r["max_input"], "models": int(r["models"] or 0), "offers": int(r["offers"] or 0)} for r in series], | |
| 90 | − "movers": [change_event(r) for r in movers], "note": "Daily medians of live USD-per-1M-token input/output prices across all provider offers valid at the end of each UTC day."} | |
| 127 | + group by d.day order by d.day""", d=days, frontier=sorted(frontier), open=open_ids, emb=emb_ids) | |
| 128 | + movers = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where ev.category = 'price' and ev.is_backfill = false and ev.occurred_at > now() - make_interval(days => :d) " | |
| 129 | + f"order by ev.importance desc, ev.occurred_at desc limit 30", d=days) | |
| 130 | + cheapest = await fetch_one(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.valid_to is null and p.output_per_mtok > 0 and p.model_id = any(cast(:ids as text[])) " | |
| 131 | + f"order by p.output_per_mtok asc, p.input_per_mtok asc nulls last limit 1", ids=sorted(frontier)) | |
| 132 | + cheapest_1m = await fetch_one(conn, f"""select {PRICE_COLS} from {PRICE_FROM} where p.valid_to is null and p.output_per_mtok > 0 and p.model_id = any(cast(:ids as text[])) | |
| 133 | + and coalesce(p.context_length, case when m.attributes->>'context_length' ~ '^[0-9]+$' then (m.attributes->>'context_length')::bigint end) >= 1000000 | |
| 134 | + order by p.output_per_mtok asc limit 1""", ids=sorted(frontier)) | |
| 135 | + current_outputs = await fetch_all(conn, "select p.output_per_mtok as v from prices p join entities m on m.id = p.model_id where p.valid_to is null and p.output_per_mtok > 0 and m.merged_into is null") | |
| 136 | + listings = await fetch_one(conn, """ | |
| 137 | + select (select count(*) from prices p where p.valid_from > now() - interval '30 days' | |
| 138 | + and not exists (select 1 from prices q where q.model_id = p.model_id and q.provider_id = p.provider_id and q.valid_from < p.valid_from)) as new_listings_30d, | |
| 139 | + (select count(*) from prices p where p.valid_to > now() - interval '30 days' | |
| 140 | + and not exists (select 1 from prices q where q.model_id = p.model_id and q.provider_id = p.provider_id and q.valid_to is null)) as delistings_30d, | |
| 141 | + (select count(*) from change_events where category = 'price' and is_backfill = false and occurred_at > now() - interval '30 days') as price_changes_30d""") | |
| 142 | + dist = [] | |
| 143 | + vals = [float(r["v"]) for r in current_outputs] | |
| 144 | + for lo, hi in BUCKETS: | |
| 145 | + n = sum(1 for v in vals if v >= lo and (hi is None or v < hi)) | |
| 146 | + dist.append({"from": lo, "to": hi, "label": f"${lo:g}–${hi:g}" if hi is not None else f"≥ ${lo:g}", "offers": n}) | |
| 147 | + out_series = [] | |
| 148 | + for r in series: | |
| 149 | + out_series.append({"day": r["day"].isoformat(), "median_input": r["median_input"], "median_output": r["median_output"], "median_frontier_output": r["median_frontier_output"], | |
| 150 | + "median_frontier_input": r["median_frontier_input"], "median_open_output": r["median_open_output"], "median_embedding_input": r["median_embedding_input"], | |
| 151 | + "min_input": r["min_input"], "max_input": r["max_input"], "min_frontier_output": r["min_frontier_output"], "models": int(r["models"] or 0), "offers": int(r["offers"] or 0), | |
| 152 | + "sample": {"models": int(r["models"] or 0), "offers": int(r["offers"] or 0), "frontier_models": int(r["frontier_models"] or 0), "frontier_offers": int(r["frontier_offers"] or 0), | |
| 153 | + "open_models": int(r["open_models"] or 0), "embedding_models": int(r["embedding_models"] or 0)}}) | |
| 154 | + return {"days": days, "series": out_series, "movers": [change_event(r) for r in movers], | |
| 155 | + "cheapest_frontier": _cheap(cheapest), "cheapest_frontier_1m_context": _cheap(cheapest_1m), | |
| 156 | + "distribution": {"metric": "output_per_mtok", "unit": "USD per 1M tokens", "buckets": dist, "offers": len(vals)}, | |
| 157 | + "new_listings_30d": int((listings or {}).get("new_listings_30d") or 0), "delistings_30d": int((listings or {}).get("delistings_30d") or 0), | |
| 158 | + "price_changes_30d": int((listings or {}).get("price_changes_30d") or 0), | |
| 159 | + "frontier": {"composition": composition, "methodology": FRONTIER_METHODOLOGY}, | |
| 160 | + "methodology": "Daily medians of live USD-per-1M-token prices across every provider offer valid at the end of each UTC day (offers with a zero or missing price are excluded " | |
| 161 | + "from that median). `median_open_output` covers models with openness open-weights/open-source; `median_embedding_input` covers models whose modalities include " | |
| 162 | + "embedding. Sample sizes are returned per day; a null median means no offer in that universe on that day. " + FRONTIER_METHODOLOGY, | |
| 163 | + "note": "Daily medians of live USD-per-1M-token input/output prices across all provider offers valid at the end of each UTC day."} | |
| 164 | + | |
| 165 | + | |
| 166 | +def _cheap(row: dict[str, Any] | None) -> dict[str, Any] | None: | |
| 167 | + if not row: | |
| 168 | + return None | |
| 169 | + pr = price_row(row) | |
| 170 | + return {"model": pr["model"], "provider": pr["provider"], "output": pr["output_per_mtok"], "input": pr["input_per_mtok"], "context_length": pr["context_length"], "price_id": pr["id"]} | |
| 171 | + | |
| 172 | + | |
| 173 | +__all__ = ["BUCKETS", "router"] | |
modified
src/aiatlas/api/routers/providers.py
+45 −4
@@ -1,4 +1,5 @@ | ||
| 1 | −"""/providers listing (with pricing aggregates) and /providers/{slug} alias.""" | |
| 1 | +"""/providers listing (with pricing aggregates) and /providers/{slug} alias. API 1.1 adds price distributions, 30-day listing churn, | |
| 2 | +organizations covered and the union of priced feature keys.""" | |
| 2 | 3 | from __future__ import annotations |
| 3 | 4 | |
| 4 | 5 | from typing import Any |
@@ -10,6 +11,11 @@ from aiatlas.api.routers.entities import detail_for_type | ||
| 10 | 11 | from aiatlas.db import connection, fetch_all |
| 11 | 12 | |
| 12 | 13 | router = APIRouter(prefix="/api/v1/providers", tags=["providers"]) |
| 14 | +FEATURE_KEYS = {"batch": ("batch_input_per_mtok", "batch_output_per_mtok", "batch_enabled", "batch"), "cached": ("cached_input_per_mtok", "cache_write_per_mtok", "cache_write_1h_per_mtok", "input_cache_write_1h", "prompt_caching"), | |
| 15 | + "fine_tuning": ("fine_tuning", "fine_tuning_input_per_mtok", "fine_tuning_training_per_mtok"), "flex": ("flex_input_per_mtok", "flex_output_per_mtok"), | |
| 16 | + "priority": ("priority", "priority_input_per_mtok", "priority_output_per_mtok"), "long_context": ("long_context_input_per_mtok", "long_context_output_per_mtok"), | |
| 17 | + "audio": ("audio", "audio_input_per_mtok", "audio_output_per_mtok"), "image": ("image_input_per_mtok", "image_output", "image_output_per_mtok", "per_image"), | |
| 18 | + "web_search": ("web_search", "search_grounding_per_1k_requests"), "free_tier": ("free_tier", "free"), "reasoning": ("internal_reasoning",), "serverless": ("serverless",)} | |
| 13 | 19 | |
| 14 | 20 | |
| 15 | 21 | @router.get("") |
@@ -22,11 +28,46 @@ async def list_providers(request: Request) -> dict[str, Any]: | ||
| 22 | 28 | (select count(*) from relations r where r.object_id = e.id and r.predicate = 'available_through' and r.valid_to is null) as listed_models, |
| 23 | 29 | (select count(*) from prices p where p.provider_id = e.id and p.valid_to is null) as price_count, |
| 24 | 30 | (select min(p.input_per_mtok) from prices p where p.provider_id = e.id and p.valid_to is null and p.input_per_mtok > 0) as min_input_per_mtok, |
| 25 | − (select min(p.output_per_mtok) from prices p where p.provider_id = e.id and p.valid_to is null and p.output_per_mtok > 0) as min_output_per_mtok | |
| 31 | + (select min(p.output_per_mtok) from prices p where p.provider_id = e.id and p.valid_to is null and p.output_per_mtok > 0) as min_output_per_mtok, | |
| 32 | + (select jsonb_build_object('min', min(x.v), 'p25', percentile_cont(0.25) within group (order by x.v), 'median', percentile_cont(0.5) within group (order by x.v), | |
| 33 | + 'p75', percentile_cont(0.75) within group (order by x.v), 'max', max(x.v), 'n', count(*)) | |
| 34 | + from (select p.input_per_mtok as v from prices p where p.provider_id = e.id and p.valid_to is null and p.input_per_mtok > 0) x) as input_dist, | |
| 35 | + (select jsonb_build_object('min', min(x.v), 'p25', percentile_cont(0.25) within group (order by x.v), 'median', percentile_cont(0.5) within group (order by x.v), | |
| 36 | + 'p75', percentile_cont(0.75) within group (order by x.v), 'max', max(x.v), 'n', count(*)) | |
| 37 | + from (select p.output_per_mtok as v from prices p where p.provider_id = e.id and p.valid_to is null and p.output_per_mtok > 0) x) as output_dist, | |
| 38 | + (select count(distinct p.model_id) from prices p where p.provider_id = e.id and p.valid_from > now() - interval '30 days' | |
| 39 | + and not exists (select 1 from prices q where q.provider_id = e.id and q.model_id = p.model_id and q.valid_from <= now() - interval '30 days')) as models_added_30d, | |
| 40 | + (select count(distinct p.model_id) from prices p where p.provider_id = e.id and p.valid_to > now() - interval '30 days' | |
| 41 | + and not exists (select 1 from prices q where q.provider_id = e.id and q.model_id = p.model_id and q.valid_to is null)) as models_removed_30d, | |
| 42 | + (select count(*) from change_events ev join prices p on p.model_id = ev.entity_id where ev.category = 'price' and ev.is_backfill = false | |
| 43 | + and ev.occurred_at > now() - interval '30 days' and p.provider_id = e.id and p.valid_to is null) as price_changes_30d, | |
| 44 | + (select count(distinct m.organization_id) from prices p join entities m on m.id = p.model_id where p.provider_id = e.id and p.valid_to is null and m.organization_id is not null) as organizations_covered, | |
| 45 | + (select coalesce(jsonb_agg(distinct k.key), '[]'::jsonb) from prices p, jsonb_object_keys(p.features) k(key) where p.provider_id = e.id and p.valid_to is null) as feature_keys, | |
| 46 | + (select count(*) from prices p where p.provider_id = e.id and p.valid_to is null and p.cached_input_per_mtok is not null) as with_cached, | |
| 47 | + (select count(*) from prices p where p.provider_id = e.id and p.valid_to is null and p.batch_input_per_mtok is not null) as with_batch | |
| 26 | 48 | from {ENTITY_FROM} where e.entity_type = 'provider' and e.merged_into is null |
| 27 | 49 | order by price_count desc, listed_models desc, e.canonical_name""") |
| 28 | − return {"items": [{**(entity_summary(r) or {}), "model_count": int(max(r["priced_models"] or 0, r["listed_models"] or 0)), "price_count": int(r["price_count"] or 0), | |
| 29 | − "min_input_per_mtok": r["min_input_per_mtok"], "min_output_per_mtok": r["min_output_per_mtok"]} for r in rows]} | |
| 50 | + items = [] | |
| 51 | + for r in rows: | |
| 52 | + keys = set(r["feature_keys"] or []) | |
| 53 | + supported = sorted(f for f, ks in FEATURE_KEYS.items() if keys & set(ks)) | |
| 54 | + if int(r["with_cached"] or 0) and "cached" not in supported: | |
| 55 | + supported.append("cached") | |
| 56 | + if int(r["with_batch"] or 0) and "batch" not in supported: | |
| 57 | + supported.append("batch") | |
| 58 | + items.append({**(entity_summary(r) or {}), "model_count": int(max(r["priced_models"] or 0, r["listed_models"] or 0)), "price_count": int(r["price_count"] or 0), | |
| 59 | + "min_input_per_mtok": r["min_input_per_mtok"], "min_output_per_mtok": r["min_output_per_mtok"], | |
| 60 | + "input_price_distribution": _dist(r["input_dist"]), "output_price_distribution": _dist(r["output_dist"]), | |
| 61 | + "models_added_30d": int(r["models_added_30d"] or 0), "models_removed_30d": int(r["models_removed_30d"] or 0), "price_changes_30d": int(r["price_changes_30d"] or 0), | |
| 62 | + "organizations_covered": int(r["organizations_covered"] or 0), "features_supported": sorted(set(supported)), "feature_keys": sorted(keys)}) | |
| 63 | + return {"items": items, "note": "Distributions are over live offers with a positive price (USD per 1M tokens). models_added/removed_30d count model listings first opened / last closed " | |
| 64 | + "in the window; price_changes_30d counts price events (not back-filled) on models the provider currently lists."} | |
| 65 | + | |
| 66 | + | |
| 67 | +def _dist(v: Any) -> dict[str, Any] | None: | |
| 68 | + if not isinstance(v, dict) or not v.get("n"): | |
| 69 | + return None | |
| 70 | + return {k: v.get(k) for k in ("min", "p25", "median", "p75", "max", "n")} | |
| 30 | 71 | |
| 31 | 72 | |
| 32 | 73 | @router.get("/{slug}") |
modified
src/aiatlas/api/routers/search.py
+53 −46
@@ -1,12 +1,12 @@ | ||
| 1 | −"""/search · /search/suggest — natural-language compiler + FTS/trigram (+ embeddings when the gateway is reachable).""" | |
| 1 | +"""/search · /search/suggest — natural-language compiler v2 + FTS/trigram (+ embeddings when the gateway is reachable).""" | |
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | |
| 4 | 4 | import asyncio |
| 5 | −import re | |
| 6 | 5 | import time |
| 7 | 6 | from typing import Any |
| 8 | 7 | |
| 9 | 8 | from fastapi import APIRouter, Depends, Query, Request |
| 9 | +from sqlalchemy.exc import DBAPIError | |
| 10 | 10 | |
| 11 | 11 | from aiatlas.api.common import ENTITY_COLS, ENTITY_FROM, ApiError, cached, entity_summary, rate_limit |
| 12 | 12 | from aiatlas.db import connection, fetch_all, fetch_val |
@@ -14,7 +14,7 @@ from aiatlas.services import cache | ||
| 14 | 14 | from aiatlas.services.embeddings import embed_query |
| 15 | 15 | from aiatlas.services.llm import gateway |
| 16 | 16 | from aiatlas.services.search import Query as SearchQuery |
| 17 | −from aiatlas.services.search import compile_query, search_entities, suggest | |
| 17 | +from aiatlas.services.search import compile_query, search_entities, suggest, verify_organization, where_for | |
| 18 | 18 | |
| 19 | 19 | router = APIRouter(prefix="/api/v1/search", tags=["search"]) |
| 20 | 20 | TOTAL_CAP = 10_000 |
@@ -24,38 +24,15 @@ EMBED_DEGRADED_KEY = "search:embed-degraded" | ||
| 24 | 24 | |
| 25 | 25 | |
| 26 | 26 | def _count_sql(q: SearchQuery) -> tuple[str, dict[str, Any]]: |
| 27 | − """Same filters as `search_entities`, without ranking — capped estimate.""" | |
| 28 | − where = ["e.merged_into is null"] | |
| 29 | − params: dict[str, Any] = {} | |
| 30 | − if q.entity_type: | |
| 31 | − where.append("e.entity_type = :etype") | |
| 32 | − params["etype"] = q.entity_type | |
| 33 | − if q.openness == "open": | |
| 34 | − where.append("(e.attributes->>'openness' in ('open-weights','open-source','open') or e.attributes->>'weights_availability' = 'open')") | |
| 35 | − elif q.openness == "proprietary": | |
| 36 | − where.append("e.attributes->>'openness' in ('proprietary','closed')") | |
| 37 | − if q.year_from: | |
| 38 | − where.append("left(e.attributes->>'release_date', 4) >= :yf") | |
| 39 | − params["yf"] = str(q.year_from) | |
| 40 | − if q.year_to: | |
| 41 | − where.append("left(e.attributes->>'release_date', 4) <= :yt") | |
| 42 | − params["yt"] = str(q.year_to) | |
| 43 | − if q.params_min: | |
| 44 | − where.append("(e.attributes->>'parameter_count')::double precision >= :pmin") | |
| 45 | − params["pmin"] = float(q.params_min) | |
| 46 | − if q.params_max: | |
| 47 | − where.append("(e.attributes->>'parameter_count')::double precision <= :pmax") | |
| 48 | − params["pmax"] = float(q.params_max) | |
| 49 | − if q.context_min: | |
| 50 | − where.append("(e.attributes->>'context_length')::double precision >= :cmin") | |
| 51 | − params["cmin"] = float(q.context_min) | |
| 52 | − for i, mod in enumerate(q.modalities): | |
| 53 | − where.append(f"e.attributes->'modalities' ? :mod{i}") | |
| 54 | − params[f"mod{i}"] = mod | |
| 55 | − if q.organization: | |
| 56 | − where.append("exists (select 1 from entities o where o.id = e.organization_id and o.canonical_name ilike :org)") | |
| 57 | − params["org"] = f"%{q.organization}%" | |
| 58 | − text = (q.filters.get("residual") or "").strip() or ("" if any([q.entity_type, q.openness, q.year_from, q.params_min, q.context_min, q.modalities]) else q.text) | |
| 27 | + """Same filters as `search_entities`, without ranking — capped estimate. All casts are regex-guarded in `where_for`.""" | |
| 28 | + import re | |
| 29 | + | |
| 30 | + where, params = where_for(q) | |
| 31 | + text = (q.residual or "").strip() or ("" if q.has_structure else q.text) | |
| 32 | + if q.benchmark and q.entity_type == "benchmark": | |
| 33 | + text = q.benchmark | |
| 34 | + if q.provider and q.entity_type == "provider": | |
| 35 | + text = q.provider | |
| 59 | 36 | if text: |
| 60 | 37 | params["q"] = text |
| 61 | 38 | params["qlike"] = f"%{text}%" |
@@ -74,17 +51,44 @@ async def search(request: Request, q: str = Query("", max_length=300), type: str | ||
| 74 | 51 | compiled.entity_type = type |
| 75 | 52 | if not q.strip() and not type: |
| 76 | 53 | raise ApiError(400, "q is required") |
| 77 | − embedding = None | |
| 78 | − residual = (compiled.filters.get("residual") or "").strip() | |
| 79 | − if gateway.available and residual and not await cache.cache_get(EMBED_DEGRADED_KEY): | |
| 80 | − try: | |
| 81 | − embedding = await asyncio.wait_for(embed_query(residual), timeout=EMBED_TIMEOUT_S) | |
| 82 | − except Exception: # noqa: BLE001 — never wait on the LLM | |
| 83 | − embedding = None | |
| 84 | − if embedding is None: # back off: FTS-only for a while instead of paying the timeout on every query | |
| 85 | − await cache.cache_set(EMBED_DEGRADED_KEY, {"since": time.time()}, EMBED_BACKOFF_S) | |
| 86 | 54 | async with connection() as conn: |
| 87 | − hits = await search_entities(conn, compiled, limit=limit, offset=offset, embedding=embedding) | |
| 55 | + org_ok = None | |
| 56 | + if compiled.organization: | |
| 57 | + org_ok = await verify_organization(conn, compiled.organization) | |
| 58 | + if org_ok is None: | |
| 59 | + # not an organization we know: demote to free text and say so | |
| 60 | + name = compiled.organization | |
| 61 | + compiled.organization = None | |
| 62 | + compiled.compiled = [c for c in compiled.compiled if c["filter"] != "organization"] | |
| 63 | + compiled.residual = " ".join(x for x in [compiled.residual, name] if x) | |
| 64 | + compiled.filters["residual"] = compiled.residual | |
| 65 | + compiled.unrecognised = sorted(set(compiled.unrecognised) | {name}) | |
| 66 | + else: | |
| 67 | + compiled.organization = org_ok["canonical_name"] | |
| 68 | + for c in compiled.compiled: | |
| 69 | + if c["filter"] == "organization": | |
| 70 | + c["value"] = {"id": org_ok["id"], "slug": org_ok["slug"], "name": org_ok["canonical_name"]} | |
| 71 | + c["label"] = f"Organization: {org_ok['canonical_name']}" | |
| 72 | + embedding = None | |
| 73 | + residual = (compiled.residual or "").strip() | |
| 74 | + if gateway.available and residual and not await cache.cache_get(EMBED_DEGRADED_KEY): | |
| 75 | + try: | |
| 76 | + embedding = await asyncio.wait_for(embed_query(residual), timeout=EMBED_TIMEOUT_S) | |
| 77 | + except Exception: # noqa: BLE001 — never wait on the LLM | |
| 78 | + embedding = None | |
| 79 | + if embedding is None: # back off: FTS-only for a while instead of paying the timeout on every query | |
| 80 | + await cache.cache_set(EMBED_DEGRADED_KEY, {"since": time.time()}, EMBED_BACKOFF_S) | |
| 81 | + if embedding is not None: | |
| 82 | + # the vector join needs `entity_embeddings` (pgvector); when the table is missing the statement aborts the connection's | |
| 83 | + # transaction, so the FTS-only fallback runs on a fresh pooled connection and embeddings are backed off for a while | |
| 84 | + try: | |
| 85 | + async with connection() as vconn: | |
| 86 | + hits = await search_entities(vconn, compiled, limit=limit, offset=offset, embedding=embedding) | |
| 87 | + except DBAPIError: | |
| 88 | + embedding = None | |
| 89 | + await cache.cache_set(EMBED_DEGRADED_KEY, {"since": time.time(), "reason": "entity_embeddings unavailable"}, EMBED_BACKOFF_S) | |
| 90 | + if embedding is None: | |
| 91 | + hits = await search_entities(conn, compiled, limit=limit, offset=offset, embedding=None) | |
| 88 | 92 | ids = [h["id"] for h in hits] |
| 89 | 93 | full = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.id = any(cast(:ids as text[]))", ids=ids) if ids else [] |
| 90 | 94 | count_sql, count_params = _count_sql(compiled) |
@@ -97,7 +101,10 @@ async def search(request: Request, q: str = Query("", max_length=300), type: str | ||
| 97 | 101 | if s: |
| 98 | 102 | s["rank"] = float(h["rank"] or 0) |
| 99 | 103 | items.append(s) |
| 100 | − return {"query": {**compiled.as_dict(), "semantic": embedding is not None}, "items": items, "total": int(total or 0), "limit": limit, "offset": offset} | |
| 104 | + query = {**compiled.as_dict(), "semantic": embedding is not None, "version": 2} | |
| 105 | + if compiled.memory_gb is not None: | |
| 106 | + query["note"] = "memory_gb is converted to an ESTIMATED parameter bound (4-bit weights, 8K context, 2 GB reserved) — see /methodology hardware_fit" | |
| 107 | + return {"query": query, "items": items, "total": int(total or 0), "limit": limit, "offset": offset} | |
| 101 | 108 | |
| 102 | 109 | |
| 103 | 110 | @router.get("/suggest", dependencies=[Depends(rate_limit("search"))]) |
modified
src/aiatlas/api/routers/stats.py
+1 −1
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/v1/stats", tags=["stats"]) | ||
| 20 | 20 | async def stats(request: Request) -> dict[str, Any]: |
| 21 | 21 | async with connection() as conn: |
| 22 | 22 | counts = await live_counts(conn) |
| 23 | − counts["entities"] = {**{t: 0 for t in MAIN_ENTITY_TYPES}, **counts["entities"]} # zero-filled: the homepage never renders a missing key | |
| 23 | + counts["entities"] = {**{t: 0 for t in (*MAIN_ENTITY_TYPES, "artifact", "model_family")}, **counts["entities"]} # zero-filled: the homepage never renders a missing key | |
| 24 | 24 | counts["archive"] = await asyncio.to_thread(archive_size) |
| 25 | 25 | counts["computed_at"] = datetime.now(UTC) |
| 26 | 26 | return counts |
modified
src/aiatlas/api/routers/timeline.py
+15 −7
@@ -1,4 +1,4 @@ | ||
| 1 | −"""/timeline — events grouped by month (global, or one entity + what it develops).""" | |
| 1 | +"""/timeline — events grouped by month (global, or one entity + what it develops). API 1.1: `occurred_at`, `is_backfill = false` by default.""" | |
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | |
| 4 | 4 | from typing import Any |
@@ -14,9 +14,13 @@ router = APIRouter(prefix="/api/v1/timeline", tags=["timeline"]) | ||
| 14 | 14 | @router.get("") |
| 15 | 15 | @cached(120) |
| 16 | 16 | async def timeline(request: Request, entity: str | None = None, year: int | None = Query(None, ge=1950, le=2100), category: str | None = None, |
| 17 | − importance_min: int | None = Query(None, ge=0, le=3), limit: int = Query(200, ge=1, le=1000)) -> dict[str, Any]: | |
| 17 | + importance_min: int | None = Query(None, ge=0, le=3), limit: int = Query(200, ge=1, le=1000), include_backfill: int = Query(0, ge=0, le=1), | |
| 18 | + date_field: str = Query("occurred", pattern="^(occurred|observed)$")) -> dict[str, Any]: | |
| 19 | + col = "ev.observed_at" if date_field == "observed" else "ev.occurred_at" | |
| 18 | 20 | where = ["ev.event_type <> 'DOCUMENT_CHANGED'"] |
| 19 | 21 | params: dict[str, Any] = {"lim": limit} |
| 22 | + if not include_backfill: | |
| 23 | + where.append("ev.is_backfill = false") | |
| 20 | 24 | async with connection() as conn: |
| 21 | 25 | if entity: |
| 22 | 26 | row = await resolve_entity(conn, entity) |
@@ -27,7 +31,7 @@ async def timeline(request: Request, entity: str | None = None, year: int | None | ||
| 27 | 31 | where.append("ev.entity_id = :eid") |
| 28 | 32 | params["eid"] = row["id"] |
| 29 | 33 | if year: |
| 30 | − where.append("extract(year from coalesce(ev.effective_at, ev.observed_at)) = :year") | |
| 34 | + where.append(f"extract(year from {col}) = :year") | |
| 31 | 35 | params["year"] = year |
| 32 | 36 | if category: |
| 33 | 37 | where.append("ev.category = any(cast(:cats as text[]))") |
@@ -35,9 +39,13 @@ async def timeline(request: Request, entity: str | None = None, year: int | None | ||
| 35 | 39 | if importance_min is not None: |
| 36 | 40 | where.append("ev.importance >= :imp") |
| 37 | 41 | params["imp"] = importance_min |
| 38 | − rows = await fetch_all(conn, f"select to_char(coalesce(ev.effective_at, ev.observed_at) at time zone 'UTC', 'YYYY-MM') as month, {EVENT_COLS} from {EVENT_FROM} " | |
| 39 | − f"where {' and '.join(where)} order by coalesce(ev.effective_at, ev.observed_at) desc, ev.id desc limit :lim", **params) | |
| 42 | + rows = await fetch_all(conn, f"select to_char({col} at time zone 'UTC', 'YYYY-MM') as month, {EVENT_COLS}, ev.occurred_at, ev.is_backfill, ev.group_key from {EVENT_FROM} " | |
| 43 | + f"where {' and '.join(where)} order by {col} desc, ev.id desc limit :lim", **params) | |
| 40 | 44 | groups: dict[str, list[dict[str, Any]]] = {} |
| 41 | 45 | for r in rows: |
| 42 | − groups.setdefault(r["month"], []).append(change_event(r)) | |
| 43 | − return {"items": [{"month": m, "count": len(evs), "events": evs} for m, evs in groups.items()], "total": len(rows)} | |
| 46 | + ev = change_event(r) | |
| 47 | + ev["occurred_at"] = r.get("occurred_at") | |
| 48 | + ev["is_backfill"] = r.get("is_backfill") | |
| 49 | + groups.setdefault(r["month"], []).append(ev) | |
| 50 | + return {"items": [{"month": m, "count": len(evs), "events": evs} for m, evs in groups.items()], "total": len(rows), "date_field": date_field, | |
| 51 | + "include_backfill": bool(include_backfill)} | |
added
src/aiatlas/services/cost.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +"""Cost arithmetic for API deployments (pure, deterministic). Prices are USD per 1M tokens; nothing is assumed when a price is missing — | |
| 2 | +the affected component is `null` and a `note` says why. | |
| 3 | + | |
| 4 | + per_request = input_tokens × effective_input / 1e6 + output_tokens × effective_output / 1e6 (+ per_request fee when published) | |
| 5 | + effective_input = (1 − cached_share) × input + cached_share × cached_input (cached_input falls back to input with a note) | |
| 6 | + batch = batch_input / batch_output when published (else the standard prices, with a note) | |
| 7 | + daily = per_request × requests_per_day · monthly = daily × 30 · annual = daily × 365 | |
| 8 | +""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +from typing import Any | |
| 12 | + | |
| 13 | +DAYS_PER_MONTH = 30 | |
| 14 | +DAYS_PER_YEAR = 365 | |
| 15 | + | |
| 16 | + | |
| 17 | +def _f(v: Any) -> float | None: | |
| 18 | + if v is None or isinstance(v, bool): | |
| 19 | + return None | |
| 20 | + try: | |
| 21 | + return float(v) | |
| 22 | + except (TypeError, ValueError): | |
| 23 | + return None | |
| 24 | + | |
| 25 | + | |
| 26 | +def compute_cost(prices: dict[str, Any], *, input_tokens: int, output_tokens: int, requests_per_day: float = 1.0, cached_share: float = 0.0, | |
| 27 | + batch: bool = False) -> dict[str, Any]: | |
| 28 | + """`prices` uses the Deployment `prices` keys (input, output, cached_input, batch_input, batch_output, per_request).""" | |
| 29 | + notes: list[str] = [] | |
| 30 | + cached_share = min(1.0, max(0.0, float(cached_share or 0.0))) | |
| 31 | + inp, outp = _f(prices.get("input")), _f(prices.get("output")) | |
| 32 | + if batch: | |
| 33 | + bi, bo = _f(prices.get("batch_input")), _f(prices.get("batch_output")) | |
| 34 | + if bi is None or bo is None: | |
| 35 | + notes.append("batch prices not published for this deployment; standard prices used") | |
| 36 | + inp = bi if bi is not None else inp | |
| 37 | + outp = bo if bo is not None else outp | |
| 38 | + eff_input = inp | |
| 39 | + if cached_share > 0 and inp is not None: | |
| 40 | + cached = _f(prices.get("cached_input")) | |
| 41 | + if cached is None: | |
| 42 | + notes.append("cached input price not published; cached share billed at the standard input price") | |
| 43 | + cached = inp | |
| 44 | + eff_input = (1 - cached_share) * inp + cached_share * cached | |
| 45 | + fee = _f(prices.get("per_request")) or 0.0 | |
| 46 | + if inp is None: | |
| 47 | + notes.append("input price unavailable") | |
| 48 | + if outp is None: | |
| 49 | + notes.append("output price unavailable") | |
| 50 | + if eff_input is None or outp is None: | |
| 51 | + per_request = None | |
| 52 | + else: | |
| 53 | + per_request = input_tokens * eff_input / 1e6 + output_tokens * outp / 1e6 + fee | |
| 54 | + daily = per_request * requests_per_day if per_request is not None else None | |
| 55 | + return { | |
| 56 | + "per_request": _round(per_request, 8), "daily": _round(daily, 6), | |
| 57 | + "monthly": _round(daily * DAYS_PER_MONTH, 4) if daily is not None else None, | |
| 58 | + "annual": _round(daily * DAYS_PER_YEAR, 4) if daily is not None else None, | |
| 59 | + "effective_input_per_mtok": _round(eff_input, 6), "effective_output_per_mtok": _round(outp, 6), "per_request_fee": fee or None, | |
| 60 | + "inputs": {"input_tokens": input_tokens, "output_tokens": output_tokens, "requests_per_day": requests_per_day, "cached_share": cached_share, "batch": batch}, | |
| 61 | + "notes": notes, | |
| 62 | + } | |
| 63 | + | |
| 64 | + | |
| 65 | +def context_fill_cost(input_per_mtok: Any, tokens: int) -> float | None: | |
| 66 | + """Cost of one fully populated context of `tokens` input tokens.""" | |
| 67 | + p = _f(input_per_mtok) | |
| 68 | + return _round(p * tokens / 1e6, 6) if p is not None else None | |
| 69 | + | |
| 70 | + | |
| 71 | +def _round(v: float | None, nd: int) -> float | None: | |
| 72 | + return round(v, nd) if v is not None else None | |
| 73 | + | |
| 74 | + | |
| 75 | +__all__ = ["DAYS_PER_MONTH", "DAYS_PER_YEAR", "compute_cost", "context_fill_cost"] | |
added
src/aiatlas/services/finder.py
+255 −0
@@ -0,0 +1,255 @@ | ||
| 1 | +"""Find-a-model (API 1.1): deterministic matching of canonical models against use-case criteria. No LLM, no composite "winner" score — | |
| 2 | +each match lists the observed facts (`why`) that satisfied each criterion and the observed dimensions; sorting is by number of satisfied | |
| 3 | +criteria, then by the best benchmark rank the model holds anywhere, then by release date. | |
| 4 | + | |
| 5 | +Rules (documented in `RULES`, returned to the client as `filters_applied`): | |
| 6 | + coding current result on a coding-category benchmark, or capabilities/modalities mention code | |
| 7 | + reasoning attributes.reasoning is true, or a current result on a reasoning/math benchmark | |
| 8 | + agentic current result on an agentic benchmark, or tool_calling is true | |
| 9 | + long_context context_length ≥ context_min (default 128 000) | |
| 10 | + vision modalities (any direction) include image | |
| 11 | + low_cost cheapest current output price ≤ max_output_price, or in the bottom quartile of all cheapest output prices | |
| 12 | + local weights downloadable (open-weights / open-source / restricted-weights) and the estimated footprint fits memory_gb | |
| 13 | + embeddings modalities include embedding (or pipeline tag says so) | |
| 14 | + chat text modality (or unspecified) and reachable through ≥ 1 current deployment or downloadable weights | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +from collections import defaultdict | |
| 19 | +from typing import Any | |
| 20 | + | |
| 21 | +from sqlalchemy.ext.asyncio import AsyncConnection | |
| 22 | + | |
| 23 | +from aiatlas.db import fetch_all | |
| 24 | +from aiatlas.ontology.licenses import LICENSES, normalize_license | |
| 25 | +from aiatlas.services import hardware_fit as hf | |
| 26 | +from aiatlas.services.frontier import all_primary_groups, rank_rows | |
| 27 | + | |
| 28 | +USE_CASES = ("coding", "reasoning", "agentic", "long_context", "vision", "low_cost", "local", "embeddings", "chat") | |
| 29 | +DOWNLOADABLE = ("open-weights", "open-source", "restricted-weights", "restricted") | |
| 30 | +CODING_CATEGORIES = ("coding",) | |
| 31 | +REASONING_CATEGORIES = ("reasoning", "math") | |
| 32 | +AGENTIC_CATEGORIES = ("agentic",) | |
| 33 | +RULES = { | |
| 34 | + "coding": "current result on a coding-category benchmark, or capabilities/modalities mention code", | |
| 35 | + "reasoning": "attributes.reasoning = true, or a current result on a reasoning/math benchmark", | |
| 36 | + "agentic": "current result on an agentic benchmark, or tool_calling = true", | |
| 37 | + "long_context": "context_length ≥ context_min (default 128 000 tokens)", | |
| 38 | + "vision": "modalities include image", | |
| 39 | + "low_cost": "cheapest current output price ≤ max_output_price, or in the bottom quartile of all models' cheapest output prices", | |
| 40 | + "local": "weights downloadable and the ESTIMATED footprint (services.hardware_fit) fits memory_gb at the requested quantisation", | |
| 41 | + "embeddings": "modalities include embedding", | |
| 42 | + "chat": "text modality and reachable (≥ 1 current deployment or downloadable weights)", | |
| 43 | + "deployment": "local → downloadable weights · api → ≥ 1 current deployment · any → no constraint", | |
| 44 | + "license": "commercial → licence key known in the ontology with commercial_use = true", | |
| 45 | + "openness": "attributes.openness in the requested categories", | |
| 46 | + "max_input_price / max_output_price": "cheapest current provider price (USD per 1M tokens) ≤ the bound", | |
| 47 | + "modalities": "every requested modality present in modalities / modalities_input / modalities_output", | |
| 48 | +} | |
| 49 | + | |
| 50 | + | |
| 51 | +def _modalities(attrs: dict[str, Any]) -> set[str]: | |
| 52 | + out: set[str] = set() | |
| 53 | + for k in ("modalities", "modalities_input", "modalities_output"): | |
| 54 | + v = attrs.get(k) | |
| 55 | + if isinstance(v, list): | |
| 56 | + out |= {str(x).strip().lower() for x in v} | |
| 57 | + elif isinstance(v, str): | |
| 58 | + out |= {x.strip().lower() for x in v.split(",") if x.strip()} | |
| 59 | + if attrs.get("vision") is True: | |
| 60 | + out.add("image") | |
| 61 | + if attrs.get("audio") is True: | |
| 62 | + out.add("audio") | |
| 63 | + return {("image" if m in ("vision", "images") else "document" if m == "pdf" else m) for m in out} | |
| 64 | + | |
| 65 | + | |
| 66 | +def _num(v: Any) -> float | None: | |
| 67 | + if isinstance(v, bool) or v is None: | |
| 68 | + return None | |
| 69 | + try: | |
| 70 | + return float(v) | |
| 71 | + except (TypeError, ValueError): | |
| 72 | + return None | |
| 73 | + | |
| 74 | + | |
| 75 | +def _mentions_code(attrs: dict[str, Any]) -> bool: | |
| 76 | + caps = attrs.get("capabilities") | |
| 77 | + text = " ".join(str(c) for c in caps).lower() if isinstance(caps, list) else str(caps or "").lower() | |
| 78 | + return "code" in text or "code" in _modalities(attrs) or str(attrs.get("pipeline_tag") or "").lower().startswith("text-generation") and "coder" in str(attrs.get("family") or "").lower() | |
| 79 | + | |
| 80 | + | |
| 81 | +async def find_models(conn: AsyncConnection, *, use_case: str | None, deployment: str = "any", memory_gb: float | None = None, quant: str = "4bit", | |
| 82 | + context_min: int | None = None, license: str = "any", openness: list[str] | None = None, max_input_price: float | None = None, | |
| 83 | + max_output_price: float | None = None, modalities: list[str] | None = None, limit: int = 30) -> dict[str, Any]: | |
| 84 | + models = await fetch_all(conn, """ | |
| 85 | + select e.id, e.slug, e.canonical_name, e.attributes, e.organization_id, o.slug as org_slug, o.canonical_name as org_name, e.first_seen_at | |
| 86 | + from entities e left join entities o on o.id = e.organization_id where e.entity_type = 'model' and e.merged_into is null""") | |
| 87 | + prices = await fetch_all(conn, """ | |
| 88 | + select p.model_id, min(p.input_per_mtok) filter (where p.input_per_mtok > 0) as min_input, min(p.output_per_mtok) filter (where p.output_per_mtok > 0) as min_output, | |
| 89 | + count(distinct p.provider_id) as providers | |
| 90 | + from prices p where p.valid_to is null group by 1""") | |
| 91 | + price_by = {p["model_id"]: p for p in prices} | |
| 92 | + groups = await all_primary_groups(conn) | |
| 93 | + ranks: dict[str, dict[str, int]] = defaultdict(dict) # model_id → {benchmark slug: rank} | |
| 94 | + categories: dict[str, set[str]] = defaultdict(set) # model_id → benchmark categories with a current result | |
| 95 | + for g in groups.values(): | |
| 96 | + cat = (g["benchmark"].get("category") or "").lower() | |
| 97 | + for r in rank_rows(g["rows"], g["higher_is_better"]): | |
| 98 | + ranks[r["model_id"]][g["benchmark"]["slug"]] = r["rank"] | |
| 99 | + if cat: | |
| 100 | + categories[r["model_id"]].add(cat) | |
| 101 | + outputs = sorted(float(p["min_output"]) for p in prices if p["min_output"] is not None) | |
| 102 | + bottom_quartile = outputs[len(outputs) // 4] if outputs else None | |
| 103 | + | |
| 104 | + ctx_min = context_min or 128_000 | |
| 105 | + quant = hf.normalize_quant(quant) | |
| 106 | + want_mods = {m.strip().lower() for m in (modalities or []) if m.strip()} | |
| 107 | + open_set = set(openness or []) | |
| 108 | + matches: list[dict[str, Any]] = [] | |
| 109 | + for m in models: | |
| 110 | + attrs = m["attributes"] or {} | |
| 111 | + why: list[str] = [] | |
| 112 | + failed = False | |
| 113 | + mods = _modalities(attrs) | |
| 114 | + pr = price_by.get(m["id"]) | |
| 115 | + cheapest_out = _num(pr["min_output"]) if pr else None | |
| 116 | + cheapest_in = _num(pr["min_input"]) if pr else None | |
| 117 | + providers = int(pr["providers"]) if pr else 0 | |
| 118 | + opn = str(attrs.get("openness") or "") | |
| 119 | + downloadable = opn in DOWNLOADABLE | |
| 120 | + ctx = _num(attrs.get("context_length")) | |
| 121 | + cats = categories.get(m["id"], set()) | |
| 122 | + estimated_fit: dict[str, Any] | None = None | |
| 123 | + | |
| 124 | + # --- hard filters | |
| 125 | + if deployment == "local" and not downloadable: | |
| 126 | + continue | |
| 127 | + if deployment == "api" and providers == 0: | |
| 128 | + continue | |
| 129 | + if open_set and opn not in open_set: | |
| 130 | + continue | |
| 131 | + if license == "commercial": | |
| 132 | + key = attrs.get("license_key") or normalize_license(attrs.get("license")) | |
| 133 | + info = LICENSES.get(key) if key else None | |
| 134 | + if not info or info.commercial_use is not True: | |
| 135 | + continue | |
| 136 | + why.append(f"licence {info.label} allows commercial use") | |
| 137 | + if max_input_price is not None: | |
| 138 | + if cheapest_in is None or cheapest_in > max_input_price: | |
| 139 | + continue | |
| 140 | + why.append(f"cheapest input ${cheapest_in:g}/M ≤ ${max_input_price:g}") | |
| 141 | + if max_output_price is not None and use_case != "low_cost": | |
| 142 | + if cheapest_out is None or cheapest_out > max_output_price: | |
| 143 | + continue | |
| 144 | + why.append(f"cheapest output ${cheapest_out:g}/M ≤ ${max_output_price:g}") | |
| 145 | + if want_mods and not want_mods <= mods: | |
| 146 | + continue | |
| 147 | + if want_mods: | |
| 148 | + why.append("modalities include " + ", ".join(sorted(want_mods))) | |
| 149 | + if memory_gb is not None and (use_case == "local" or deployment == "local"): | |
| 150 | + estimated_fit = hf.fit_detailed(attrs, memory_gb, quant=quant, context=min(int(ctx or 8192), 8192)) | |
| 151 | + if estimated_fit is None or not estimated_fit["fits"]: | |
| 152 | + continue | |
| 153 | + why.append(f"estimated {estimated_fit['estimated_memory_gb']} GB at {quant} fits {memory_gb:g} GB (estimate)") | |
| 154 | + | |
| 155 | + # --- use-case rule (one criterion; `why` records the observed fact) | |
| 156 | + if use_case == "coding": | |
| 157 | + if cats & set(CODING_CATEGORIES): | |
| 158 | + why.append("current result on a coding benchmark: " + ", ".join(sorted(b for b in ranks[m["id"]] if groups_cat(groups, b) == "coding")[:4])) | |
| 159 | + elif _mentions_code(attrs): | |
| 160 | + why.append("capabilities/modalities mention code") | |
| 161 | + else: | |
| 162 | + failed = True | |
| 163 | + elif use_case == "reasoning": | |
| 164 | + if attrs.get("reasoning") is True: | |
| 165 | + why.append("attributes.reasoning = true") | |
| 166 | + elif cats & set(REASONING_CATEGORIES): | |
| 167 | + why.append("current result on a reasoning/math benchmark") | |
| 168 | + else: | |
| 169 | + failed = True | |
| 170 | + elif use_case == "agentic": | |
| 171 | + if cats & set(AGENTIC_CATEGORIES): | |
| 172 | + why.append("current result on an agentic benchmark") | |
| 173 | + elif attrs.get("tool_calling") is True: | |
| 174 | + why.append("tool_calling = true") | |
| 175 | + else: | |
| 176 | + failed = True | |
| 177 | + elif use_case == "long_context": | |
| 178 | + if ctx is not None and ctx >= ctx_min: | |
| 179 | + why.append(f"context_length {int(ctx):,} ≥ {ctx_min:,}") | |
| 180 | + else: | |
| 181 | + failed = True | |
| 182 | + elif use_case == "vision": | |
| 183 | + if "image" in mods: | |
| 184 | + why.append("modalities include image") | |
| 185 | + else: | |
| 186 | + failed = True | |
| 187 | + elif use_case == "low_cost": | |
| 188 | + bound = max_output_price if max_output_price is not None else bottom_quartile | |
| 189 | + if cheapest_out is not None and bound is not None and cheapest_out <= bound: | |
| 190 | + why.append(f"cheapest output ${cheapest_out:g}/M ≤ ${bound:g}/M " + ("(bound)" if max_output_price is not None else "(bottom quartile of observed prices)")) | |
| 191 | + else: | |
| 192 | + failed = True | |
| 193 | + elif use_case == "local": | |
| 194 | + if not downloadable: | |
| 195 | + failed = True | |
| 196 | + else: | |
| 197 | + why.append(f"openness {opn}: weights downloadable") | |
| 198 | + if memory_gb is None: | |
| 199 | + estimated_fit = hf.fit_detailed(attrs, 64.0, quant=quant, context=8192) | |
| 200 | + if estimated_fit is not None: | |
| 201 | + why.append(f"estimated {estimated_fit['estimated_memory_gb']} GB at {quant} (reference 64 GB device, estimate)") | |
| 202 | + elif use_case == "embeddings": | |
| 203 | + if "embedding" in mods or "embedding" in str(attrs.get("pipeline_tag") or "").lower(): | |
| 204 | + why.append("modalities include embedding") | |
| 205 | + else: | |
| 206 | + failed = True | |
| 207 | + elif use_case == "chat": | |
| 208 | + if (not mods or "text" in mods) and (providers > 0 or downloadable): | |
| 209 | + why.append("text modality and reachable (" + (f"{providers} providers" if providers else "downloadable weights") + ")") | |
| 210 | + else: | |
| 211 | + failed = True | |
| 212 | + if failed: | |
| 213 | + continue | |
| 214 | + if context_min is not None and use_case != "long_context": | |
| 215 | + if ctx is None or ctx < context_min: | |
| 216 | + continue | |
| 217 | + why.append(f"context_length {int(ctx):,} ≥ {context_min:,}") | |
| 218 | + best_rank = min(ranks[m["id"]].values()) if ranks.get(m["id"]) else None | |
| 219 | + matches.append({ | |
| 220 | + "model": {"id": m["id"], "slug": m["slug"], "name": m["canonical_name"], "entity_type": "model", | |
| 221 | + "organization": {"id": m["organization_id"], "slug": m["org_slug"], "name": m["org_name"]} if m["organization_id"] else None}, | |
| 222 | + "why": why, | |
| 223 | + "observed": {"context_length": ctx, "parameter_count": _num(attrs.get("parameter_count")), "openness": opn or None, "license": attrs.get("license"), | |
| 224 | + "license_key": attrs.get("license_key") or normalize_license(attrs.get("license")), "modalities": sorted(mods), "reasoning": attrs.get("reasoning"), | |
| 225 | + "tool_calling": attrs.get("tool_calling"), "release_date": attrs.get("release_date"), "cheapest_input_per_mtok": cheapest_in, | |
| 226 | + "cheapest_output_per_mtok": cheapest_out, "providers": providers, "benchmark_ranks": dict(sorted(ranks.get(m["id"], {}).items())), "best_rank": best_rank}, | |
| 227 | + **({"estimated_fit": estimated_fit} if estimated_fit else {}), | |
| 228 | + "_sort": (-len(why), best_rank if best_rank is not None else 10_000, -(_date_ord(attrs.get("release_date")))), | |
| 229 | + }) | |
| 230 | + matches.sort(key=lambda x: x["_sort"]) | |
| 231 | + for x in matches: | |
| 232 | + x.pop("_sort", None) | |
| 233 | + return {"matches": matches[:limit], "total": len(matches), | |
| 234 | + "filters_applied": {k: v for k, v in {"use_case": use_case, "deployment": deployment, "memory_gb": memory_gb, "quant": quant if memory_gb is not None or use_case == "local" else None, | |
| 235 | + "context_min": context_min, "license": license, "openness": openness or None, "max_input_price": max_input_price, | |
| 236 | + "max_output_price": max_output_price, "modalities": sorted(want_mods) or None}.items() if v not in (None, "any")}, | |
| 237 | + "rules": {k: RULES[k] for k in RULES if k == use_case or k not in USE_CASES}, | |
| 238 | + "note": "Deterministic filters over observed attributes, current prices and current benchmark results of canonical models; sorted by number of satisfied " | |
| 239 | + "criteria, then best benchmark rank, then release date. No composite score. Hardware fit is an estimate."} | |
| 240 | + | |
| 241 | + | |
| 242 | +def groups_cat(groups: dict[str, dict[str, Any]], slug: str) -> str: | |
| 243 | + for g in groups.values(): | |
| 244 | + if g["benchmark"]["slug"] == slug: | |
| 245 | + return (g["benchmark"].get("category") or "").lower() | |
| 246 | + return "" | |
| 247 | + | |
| 248 | + | |
| 249 | +def _date_ord(v: Any) -> int: | |
| 250 | + s = str(v or "") | |
| 251 | + digits = "".join(ch for ch in s[:10] if ch.isdigit()) | |
| 252 | + return int(digits.ljust(8, "0")[:8]) if digits else 0 | |
| 253 | + | |
| 254 | + | |
| 255 | +__all__ = ["RULES", "USE_CASES", "find_models"] | |
added
src/aiatlas/services/frontier.py
+303 −0
@@ -0,0 +1,303 @@ | ||
| 1 | +"""Benchmark comparability groups, per-model leaderboards and the "frontier" model set (API 1.1). Deterministic, no LLM. | |
| 2 | + | |
| 3 | +A *group* is (benchmark, canonical metric, config_key): the comparability-relevant part of the result configuration hashed by | |
| 4 | +`ontology.benchmarks.config_key` (task keys + metric). Rows written before migration 0003 carry a NULL `config_key` / `trust_level`; | |
| 5 | +both are recomputed here from `config` and the source key so the API behaves the same before and after canonicalisation. | |
| 6 | + | |
| 7 | +Leaderboards are ONE row per canonical model: the best current row of that model inside the chosen group. Effort variants folded into | |
| 8 | +a model land in the same group (reasoning effort is a *condition*, not a task key) and are summarised in `config`.""" | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +from collections import Counter, defaultdict | |
| 12 | +from datetime import UTC, datetime, timedelta | |
| 13 | +from typing import Any | |
| 14 | + | |
| 15 | +from sqlalchemy.ext.asyncio import AsyncConnection | |
| 16 | + | |
| 17 | +from aiatlas.db import fetch_all | |
| 18 | +from aiatlas.ontology.benchmarks import ( | |
| 19 | + CONDITION_KEYS, | |
| 20 | + METRICS, | |
| 21 | + TASK_KEYS, | |
| 22 | + TRUST_LABELS, | |
| 23 | + comparability, | |
| 24 | + config_key, | |
| 25 | + family_of, | |
| 26 | + normalize_metric, | |
| 27 | + trust_level, | |
| 28 | +) | |
| 29 | +from aiatlas.services import cache | |
| 30 | + | |
| 31 | +RESULT_SELECT = """ | |
| 32 | + select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.unit, r.higher_is_better, r.config, r.evaluated_at, r.observed_at, r.source_url, r.tier, | |
| 33 | + r.confidence, r.valid_to, r.config_key, r.trust_level, r.variant, r.run_group, r.is_current, r.extractor, s.key as source_key, | |
| 34 | + m.slug as model_slug, m.canonical_name as model_name, m.entity_type as model_type, m.merged_into as model_merged_into, m.organization_id, | |
| 35 | + m.attributes as model_attrs, m.family_id, mo.slug as org_slug, mo.canonical_name as org_name, | |
| 36 | + b.slug as benchmark_slug, b.canonical_name as benchmark_name, b.attributes as benchmark_attrs | |
| 37 | + from benchmark_results r | |
| 38 | + join entities m on m.id = r.model_id left join entities mo on mo.id = m.organization_id | |
| 39 | + join entities b on b.id = r.benchmark_id | |
| 40 | + left join sources s on s.id = r.source_id | |
| 41 | +""" | |
| 42 | +CURRENT = "r.valid_to is null and r.is_current" | |
| 43 | +CANONICAL_MODEL = "m.entity_type = 'model' and m.merged_into is null" | |
| 44 | +FRONTIER_CACHE_KEY = "frontier:model-ids:v1" | |
| 45 | +FRONTIER_TTL_S = 600 | |
| 46 | +FRONTIER_METHODOLOGY = ("Frontier models = canonical models released in the last 12 months by organizations with at least 3 canonical models, " | |
| 47 | + "OR holding a top-10 rank in the primary comparability group of at least one benchmark. Artifacts and folded variants are excluded. " | |
| 48 | + "No composite score is used to pick them.") | |
| 49 | + | |
| 50 | + | |
| 51 | +# ------------------------------------------------------------------------------------------------------------------ row enrichment | |
| 52 | + | |
| 53 | + | |
| 54 | +def enrich(row: dict[str, Any]) -> dict[str, Any]: | |
| 55 | + """Fill `config_key` / `trust_level` / `metric_canonical` when the writer has not (pre-0003 rows).""" | |
| 56 | + cfg = row.get("config") or {} | |
| 57 | + metric = normalize_metric(row.get("metric")) or (row.get("metric") or "").strip().lower() or "score" | |
| 58 | + row["metric_canonical"] = metric | |
| 59 | + if not row.get("config_key"): | |
| 60 | + row["config_key"] = config_key(cfg, row.get("metric")) | |
| 61 | + if not row.get("trust_level"): | |
| 62 | + row["trust_level"] = trust_level(row.get("source_key"), cfg, extractor=row.get("extractor") or "deterministic") | |
| 63 | + return row | |
| 64 | + | |
| 65 | + | |
| 66 | +def group_label(metric: str, cfg: dict[str, Any] | None) -> str: | |
| 67 | + parts = [f"{k}={cfg[k]}" for k in TASK_KEYS if cfg and cfg.get(k) not in (None, "", [], {})] | |
| 68 | + return metric + (" · " + " · ".join(str(p) for p in parts) if parts else "") | |
| 69 | + | |
| 70 | + | |
| 71 | +def config_summary(cfg: dict[str, Any] | None) -> dict[str, Any]: | |
| 72 | + """Task + condition keys only (bookkeeping keys such as aa_slug are dropped).""" | |
| 73 | + return {k: cfg[k] for k in (*TASK_KEYS, *CONDITION_KEYS) if cfg and cfg.get(k) not in (None, "", [], {})} | |
| 74 | + | |
| 75 | + | |
| 76 | +def direction_of(rows: list[dict[str, Any]], metric: str) -> bool: | |
| 77 | + votes = Counter(bool(r.get("higher_is_better")) for r in rows if r.get("higher_is_better") is not None) | |
| 78 | + if votes: | |
| 79 | + return votes.most_common(1)[0][0] | |
| 80 | + spec = METRICS.get(metric) | |
| 81 | + return bool(spec["higher_is_better"]) if spec else True | |
| 82 | + | |
| 83 | + | |
| 84 | +# ------------------------------------------------------------------------------------------------------------------ loading | |
| 85 | + | |
| 86 | + | |
| 87 | +async def load_results(conn: AsyncConnection, *, benchmark_ids: list[str] | None = None, model_ids: list[str] | None = None, current_only: bool = True, | |
| 88 | + canonical_models_only: bool = True, limit: int = 50_000) -> list[dict[str, Any]]: | |
| 89 | + where = [] | |
| 90 | + params: dict[str, Any] = {"lim": limit} | |
| 91 | + if benchmark_ids is not None: | |
| 92 | + where.append("r.benchmark_id = any(cast(:bids as text[]))") | |
| 93 | + params["bids"] = benchmark_ids | |
| 94 | + if model_ids is not None: | |
| 95 | + where.append("r.model_id = any(cast(:mids as text[]))") | |
| 96 | + params["mids"] = model_ids | |
| 97 | + if current_only: | |
| 98 | + where.append(CURRENT) | |
| 99 | + if canonical_models_only: | |
| 100 | + where.append(CANONICAL_MODEL) | |
| 101 | + sql = RESULT_SELECT + " where " + (" and ".join(where) or "true") + " order by r.benchmark_id, r.observed_at desc limit :lim" | |
| 102 | + return [enrich(r) for r in await fetch_all(conn, sql, **params)] | |
| 103 | + | |
| 104 | + | |
| 105 | +# ------------------------------------------------------------------------------------------------------------------ grouping | |
| 106 | + | |
| 107 | + | |
| 108 | +def group_rows(rows: list[dict[str, Any]]) -> dict[tuple[str, str, str], dict[str, Any]]: | |
| 109 | + """{(benchmark_id, metric, config_key): {rows, metric, config_key, label, n, models, higher_is_better, representative config}}.""" | |
| 110 | + groups: dict[tuple[str, str, str], dict[str, Any]] = {} | |
| 111 | + for r in rows: | |
| 112 | + k = (r["benchmark_id"], r["metric_canonical"], r["config_key"]) | |
| 113 | + g = groups.get(k) | |
| 114 | + if g is None: | |
| 115 | + g = groups[k] = {"benchmark_id": r["benchmark_id"], "metric": r["metric_canonical"], "config_key": r["config_key"], "rows": [], "models": set(), | |
| 116 | + "config": {k2: r["config"][k2] for k2 in TASK_KEYS if (r.get("config") or {}).get(k2) not in (None, "", [], {})}} | |
| 117 | + g["rows"].append(r) | |
| 118 | + g["models"].add(r["model_id"]) | |
| 119 | + for g in groups.values(): | |
| 120 | + g["n"] = len(g["rows"]) | |
| 121 | + g["model_count"] = len(g["models"]) | |
| 122 | + g["label"] = group_label(g["metric"], g["config"]) | |
| 123 | + g["higher_is_better"] = direction_of(g["rows"], g["metric"]) | |
| 124 | + g["trust_mix"] = dict(Counter(r["trust_level"] for r in g["rows"])) | |
| 125 | + return groups | |
| 126 | + | |
| 127 | + | |
| 128 | +def primary_metric(benchmark_attrs: dict[str, Any] | None, groups: list[dict[str, Any]]) -> str | None: | |
| 129 | + """Registry metric when it has current rows, else the most populated metric that is not a LiveBench per-category average.""" | |
| 130 | + declared = normalize_metric((benchmark_attrs or {}).get("metric")) | |
| 131 | + present = Counter() | |
| 132 | + for g in groups: | |
| 133 | + present[g["metric"]] += g["n"] | |
| 134 | + if declared and present.get(declared): | |
| 135 | + return declared | |
| 136 | + ranked = [m for m, _ in present.most_common() if not m.startswith("category:")] or [m for m, _ in present.most_common()] | |
| 137 | + return ranked[0] if ranked else declared | |
| 138 | + | |
| 139 | + | |
| 140 | +def primary_group(benchmark_attrs: dict[str, Any] | None, groups: list[dict[str, Any]]) -> dict[str, Any] | None: | |
| 141 | + metric = primary_metric(benchmark_attrs, groups) | |
| 142 | + cands = [g for g in groups if g["metric"] == metric] | |
| 143 | + if not cands: | |
| 144 | + return None | |
| 145 | + return max(cands, key=lambda g: (g["model_count"], g["n"], g["config_key"])) | |
| 146 | + | |
| 147 | + | |
| 148 | +def best_per_model(rows: list[dict[str, Any]], higher_is_better: bool) -> list[dict[str, Any]]: | |
| 149 | + best: dict[str, dict[str, Any]] = {} | |
| 150 | + for r in rows: | |
| 151 | + cur = best.get(r["model_id"]) | |
| 152 | + if cur is None or (r["score"] > cur["score"] if higher_is_better else r["score"] < cur["score"]) or \ | |
| 153 | + (r["score"] == cur["score"] and (r.get("evaluated_at") or r["observed_at"]) > (cur.get("evaluated_at") or cur["observed_at"])): | |
| 154 | + best[r["model_id"]] = r | |
| 155 | + return sorted(best.values(), key=lambda r: (-r["score"] if higher_is_better else r["score"], r["model_name"] or "")) | |
| 156 | + | |
| 157 | + | |
| 158 | +def rank_rows(rows: list[dict[str, Any]], higher_is_better: bool) -> list[dict[str, Any]]: | |
| 159 | + """Competition ranking (1, 2, 2, 4) over best-per-model rows.""" | |
| 160 | + ranked = best_per_model(rows, higher_is_better) | |
| 161 | + out: list[dict[str, Any]] = [] | |
| 162 | + prev_score, prev_rank = None, 0 | |
| 163 | + for i, r in enumerate(ranked, start=1): | |
| 164 | + rank = prev_rank if prev_score is not None and r["score"] == prev_score else i | |
| 165 | + prev_score, prev_rank = r["score"], rank | |
| 166 | + out.append({**r, "rank": rank}) | |
| 167 | + return out | |
| 168 | + | |
| 169 | + | |
| 170 | +def leaderboard_rows(group: dict[str, Any], *, history_rows: list[dict[str, Any]] | None = None, comparable_only: bool = False) -> list[dict[str, Any]]: | |
| 171 | + """Public leaderboard rows for one group. `history_rows` (closed rows of the same group) give the previous rank per model.""" | |
| 172 | + hib = group["higher_is_better"] | |
| 173 | + ranked = rank_rows(group["rows"], hib) | |
| 174 | + leader = ranked[0] if ranked else None | |
| 175 | + prev_rank: dict[str, int] = {} | |
| 176 | + if history_rows: | |
| 177 | + prev_rank = {r["model_id"]: r["rank"] for r in rank_rows(history_rows, hib)} | |
| 178 | + out: list[dict[str, Any]] = [] | |
| 179 | + for r in ranked: | |
| 180 | + level, reasons = comparability(leader["config"] if leader else None, r.get("config"), leader["metric"] if leader else None, r.get("metric")) if leader else ("comparable", []) | |
| 181 | + if comparable_only and level != "comparable": | |
| 182 | + continue | |
| 183 | + prev = prev_rank.get(r["model_id"]) | |
| 184 | + out.append({ | |
| 185 | + "rank": r["rank"], "model": _model_ref(r), "score": r["score"], "metric": r["metric_canonical"], "unit": r.get("unit"), "higher_is_better": hib, | |
| 186 | + "delta_rank": (prev - r["rank"]) if prev is not None else None, "previous_rank": prev, | |
| 187 | + "trust_level": r["trust_level"], "trust_label": TRUST_LABELS.get(r["trust_level"], r["trust_level"]), "config": config_summary(r.get("config")), | |
| 188 | + "config_key": r["config_key"], "comparability": level, "comparability_reasons": reasons, "evaluated_at": r.get("evaluated_at"), "observed_at": r["observed_at"], | |
| 189 | + "source_url": r.get("source_url"), "tier": r.get("tier"), "result_id": r["id"], "n_rows": sum(1 for x in group["rows"] if x["model_id"] == r["model_id"]), | |
| 190 | + }) | |
| 191 | + return out | |
| 192 | + | |
| 193 | + | |
| 194 | +def _model_ref(r: dict[str, Any]) -> dict[str, Any]: | |
| 195 | + attrs = r.get("model_attrs") or {} | |
| 196 | + return {"id": r["model_id"], "slug": r["model_slug"], "name": r["model_name"], "entity_type": r.get("model_type", "model"), | |
| 197 | + "organization": {"id": r.get("organization_id"), "slug": r.get("org_slug"), "name": r.get("org_name")} if r.get("organization_id") else None, | |
| 198 | + "attributes": {k: attrs[k] for k in ("openness", "parameter_count", "context_length", "release_date", "modalities", "license", "family") if attrs.get(k) not in (None, "", [])}} | |
| 199 | + | |
| 200 | + | |
| 201 | +def group_summary(g: dict[str, Any]) -> dict[str, Any]: | |
| 202 | + return {"metric": g["metric"], "config_key": g["config_key"], "label": g["label"], "n": g["n"], "model_count": g["model_count"], "config": g["config"], | |
| 203 | + "higher_is_better": g["higher_is_better"], "trust_mix": g["trust_mix"]} | |
| 204 | + | |
| 205 | + | |
| 206 | +def frontier_series(rows: list[dict[str, Any]], higher_is_better: bool) -> list[dict[str, Any]]: | |
| 207 | + """History of the leader: each time a new best score appears (ordered by coalesce(evaluated_at, observed_at)).""" | |
| 208 | + ordered = sorted(rows, key=lambda r: ((r.get("evaluated_at") or r["observed_at"]), r["observed_at"], r["id"])) | |
| 209 | + best: float | None = None | |
| 210 | + out: list[dict[str, Any]] = [] | |
| 211 | + for r in ordered: | |
| 212 | + s = float(r["score"]) | |
| 213 | + better = best is None or (s > best if higher_is_better else s < best) | |
| 214 | + if better: | |
| 215 | + best = s | |
| 216 | + out.append({"date": (r.get("evaluated_at") or r["observed_at"]), "model": _model_ref(r), "score": s, "trust_level": r["trust_level"], | |
| 217 | + "config": config_summary(r.get("config")), "result_id": r["id"]}) | |
| 218 | + return out | |
| 219 | + | |
| 220 | + | |
| 221 | +def leader_at(rows: list[dict[str, Any]], at: datetime, benchmark_attrs: dict[str, Any] | None) -> dict[str, Any] | None: | |
| 222 | + """Leader of the primary group as it was known at `at`: rows observed at or before `at` and not closed before `at`.""" | |
| 223 | + visible = [r for r in rows if r["observed_at"] <= at and (r.get("valid_to") is None or r["valid_to"] > at)] | |
| 224 | + if not visible: | |
| 225 | + return None | |
| 226 | + groups = list(group_rows(visible).values()) | |
| 227 | + pg = primary_group(benchmark_attrs, groups) | |
| 228 | + if not pg: | |
| 229 | + return None | |
| 230 | + ranked = rank_rows(pg["rows"], pg["higher_is_better"]) | |
| 231 | + if not ranked: | |
| 232 | + return None | |
| 233 | + top = ranked[0] | |
| 234 | + return {"model": _model_ref(top), "score": top["score"], "metric": pg["metric"], "config_key": pg["config_key"], "group_label": pg["label"], | |
| 235 | + "trust_level": top["trust_level"], "n_models": pg["model_count"], "as_of": at} | |
| 236 | + | |
| 237 | + | |
| 238 | +# ------------------------------------------------------------------------------------------------------------------ benchmark catalogue | |
| 239 | + | |
| 240 | + | |
| 241 | +async def benchmark_meta(conn: AsyncConnection, ids: list[str] | None = None) -> dict[str, dict[str, Any]]: | |
| 242 | + where = "e.entity_type = 'benchmark' and e.merged_into is null" + (" and e.id = any(cast(:ids as text[]))" if ids is not None else "") | |
| 243 | + rows = await fetch_all(conn, f"select e.id, e.slug, e.canonical_name, e.attributes from entities e where {where}", ids=ids) | |
| 244 | + out: dict[str, dict[str, Any]] = {} | |
| 245 | + for r in rows: | |
| 246 | + attrs = r["attributes"] or {} | |
| 247 | + fam, variant = family_of(r["slug"]) | |
| 248 | + out[r["id"]] = {"id": r["id"], "slug": r["slug"], "name": r["canonical_name"], "attributes": attrs, "category": attrs.get("category"), | |
| 249 | + "family": attrs.get("family") or fam, "variant": attrs.get("variant") or variant, | |
| 250 | + "metric": normalize_metric(attrs.get("metric")) or attrs.get("metric"), "unit": attrs.get("unit"), | |
| 251 | + "direction": attrs.get("direction") or ("higher" if METRICS.get(normalize_metric(attrs.get("metric")) or "", {}).get("higher_is_better", True) else "lower")} | |
| 252 | + return out | |
| 253 | + | |
| 254 | + | |
| 255 | +async def all_primary_groups(conn: AsyncConnection, *, min_results: int = 1) -> dict[str, dict[str, Any]]: | |
| 256 | + """{benchmark_id: primary group (with rows)} across every benchmark, from current rows of canonical models.""" | |
| 257 | + meta = await benchmark_meta(conn) | |
| 258 | + rows = await load_results(conn) | |
| 259 | + by_bench: dict[str, list[dict[str, Any]]] = defaultdict(list) | |
| 260 | + for r in rows: | |
| 261 | + by_bench[r["benchmark_id"]].append(r) | |
| 262 | + out: dict[str, dict[str, Any]] = {} | |
| 263 | + for bid, brows in by_bench.items(): | |
| 264 | + groups = list(group_rows(brows).values()) | |
| 265 | + pg = primary_group(meta.get(bid, {}).get("attributes"), groups) | |
| 266 | + if pg and pg["n"] >= min_results: | |
| 267 | + pg = dict(pg) | |
| 268 | + pg["benchmark"] = meta.get(bid) or {"id": bid, "slug": brows[0]["benchmark_slug"], "name": brows[0]["benchmark_name"]} | |
| 269 | + pg["all_groups"] = groups | |
| 270 | + out[bid] = pg | |
| 271 | + return out | |
| 272 | + | |
| 273 | + | |
| 274 | +# ------------------------------------------------------------------------------------------------------------------ frontier model set | |
| 275 | + | |
| 276 | + | |
| 277 | +async def frontier_model_ids(conn: AsyncConnection, *, use_cache: bool = True) -> tuple[set[str], dict[str, Any]]: | |
| 278 | + """Frontier composition (see FRONTIER_METHODOLOGY). Returns (ids, sample counts).""" | |
| 279 | + if use_cache: | |
| 280 | + hit = await cache.cache_get(FRONTIER_CACHE_KEY) | |
| 281 | + if hit: | |
| 282 | + return set(hit["ids"]), hit["composition"] | |
| 283 | + since = (datetime.now(UTC) - timedelta(days=365)).date().isoformat() | |
| 284 | + recent = await fetch_all(conn, """ | |
| 285 | + select e.id from entities e where e.entity_type = 'model' and e.merged_into is null and e.attributes->>'release_date' >= :since | |
| 286 | + and e.organization_id in (select organization_id from entities where entity_type = 'model' and merged_into is null and organization_id is not null | |
| 287 | + group by organization_id having count(*) >= 3)""", since=since) | |
| 288 | + recent_ids = {r["id"] for r in recent} | |
| 289 | + top10: set[str] = set() | |
| 290 | + groups = await all_primary_groups(conn) | |
| 291 | + for g in groups.values(): | |
| 292 | + for r in rank_rows(g["rows"], g["higher_is_better"]): | |
| 293 | + if r["rank"] <= 10: | |
| 294 | + top10.add(r["model_id"]) | |
| 295 | + ids = recent_ids | top10 | |
| 296 | + composition = {"recent_by_active_orgs": len(recent_ids), "top10_on_a_benchmark": len(top10), "total": len(ids), "since": since} | |
| 297 | + await cache.cache_set(FRONTIER_CACHE_KEY, {"ids": sorted(ids), "composition": composition}, FRONTIER_TTL_S) | |
| 298 | + return ids, composition | |
| 299 | + | |
| 300 | + | |
| 301 | +__all__ = ["CANONICAL_MODEL", "CURRENT", "FRONTIER_METHODOLOGY", "RESULT_SELECT", "all_primary_groups", "benchmark_meta", "best_per_model", "config_summary", | |
| 302 | + "direction_of", "enrich", "frontier_model_ids", "frontier_series", "group_label", "group_rows", "group_summary", "leader_at", "leaderboard_rows", | |
| 303 | + "load_results", "primary_group", "primary_metric", "rank_rows"] | |
modified
src/aiatlas/services/hardware_fit.py
+107 −12
@@ -1,25 +1,38 @@ | ||
| 1 | 1 | """ESTIMATED memory footprint of a model on a piece of hardware. Transparent formula, always labelled as an estimate: |
| 2 | 2 | |
| 3 | 3 | weights = parameter_count × bytes_per_param × 1.15 (runtime overhead: activations, buffers, fragmentation) |
| 4 | − kv_cache = 0.5 GB per 8 192 tokens of context (coarse, architecture-agnostic) | |
| 5 | − fits = estimated_memory_gb ≤ hardware memory − 2 GB (OS / framework headroom) | |
| 4 | + — or the OBSERVED `file_size_gb` of an artifact when one is available (flagged `weights_source: observed`) | |
| 5 | + kv_cache = per layer: 2 (K and V) × kv_heads × head_dim × bytes × context × batch — when the architecture metadata is known | |
| 6 | + (`num_hidden_layers`, `num_key_value_heads` | `num_attention_heads`, `head_dim` | `hidden_size`); otherwise the documented | |
| 7 | + heuristic 0.5 GB per 8 192 tokens (× batch), architecture-agnostic | |
| 8 | + fits = estimated_memory_gb ≤ hardware memory − 2 GB (OS / framework headroom); multi-GPU = sum of device memory, interconnect ignored | |
| 6 | 9 | """ |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
| 9 | 12 | from typing import Any |
| 10 | 13 | |
| 11 | −BYTES_PER_PARAM = {"4bit": 0.5, "8bit": 1.0, "fp16": 2.0} | |
| 14 | +BYTES_PER_PARAM = {"4bit": 0.5, "8bit": 1.0, "fp16": 2.0, "bf16": 2.0, "fp8": 1.0, "int4": 0.5, "int8": 1.0, "fp32": 4.0} | |
| 15 | +KV_BYTES = {"fp16": 2.0, "bf16": 2.0, "fp8": 1.0, "int8": 1.0, "8bit": 1.0, "4bit": 2.0, "int4": 2.0, "fp32": 4.0} # KV cache dtype (4-bit weights usually keep fp16 KV) | |
| 12 | 16 | OVERHEAD = 1.15 |
| 13 | 17 | KV_GB_PER_8K = 0.5 |
| 14 | 18 | RESERVED_GB = 2.0 |
| 15 | 19 | ASSUMPTIONS = [ |
| 16 | − "Estimated, not measured: weights = parameters × bytes/param × 1.15 runtime overhead.", | |
| 20 | + "Estimated, not measured: weights = parameters × bytes/param × 1.15 runtime overhead (or the observed artifact file size when one is recorded).", | |
| 17 | 21 | "bytes/param: 4bit = 0.5, 8bit = 1.0, fp16 = 2.0 (uniform quantization, no per-layer exceptions).", |
| 18 | − "KV cache approximated at 0.5 GB per 8 192 tokens of context, independent of architecture (GQA/MLA models need less).", | |
| 22 | + "KV cache: 2 × layers × kv_heads × head_dim × 2 bytes × context × batch when the architecture is known; otherwise 0.5 GB per 8 192 tokens " | |
| 23 | + "(× batch), independent of architecture (GQA/MLA models need less).", | |
| 19 | 24 | "A model 'fits' when the estimate is at most the device memory minus 2 GB reserved for the OS and framework.", |
| 20 | 25 | "Mixture-of-experts models are estimated on total parameters (all experts must be resident); active parameters are ignored.", |
| 21 | 26 | "Device memory uses the largest configuration when several are listed (e.g. Apple silicon tiers).", |
| 27 | + "Multi-GPU: device memories are summed; interconnect bandwidth, tensor-parallel replication and pipeline bubbles are not modelled.", | |
| 22 | 28 | ] |
| 29 | +QUANT_ALIASES = {"q4": "4bit", "int4": "4bit", "nf4": "4bit", "gguf-q4": "4bit", "mlx-4bit": "4bit", "q8": "8bit", "int8": "8bit", "fp8": "8bit", | |
| 30 | + "f16": "fp16", "half": "fp16", "bf16": "fp16", "fp16": "fp16", "4bit": "4bit", "8bit": "8bit", "fp32": "fp32", "f32": "fp32"} | |
| 31 | + | |
| 32 | + | |
| 33 | +def normalize_quant(q: str | None) -> str: | |
| 34 | + s = (q or "4bit").strip().lower() | |
| 35 | + return QUANT_ALIASES.get(s, s if s in BYTES_PER_PARAM else "4bit") | |
| 23 | 36 | |
| 24 | 37 | |
| 25 | 38 | def estimate_memory_gb(parameter_count: float, quant: str = "4bit", context: int = 8192) -> float: |
@@ -45,24 +58,106 @@ def hardware_memory_gb(attrs: dict[str, Any] | None) -> float | None: | ||
| 45 | 58 | return None |
| 46 | 59 | |
| 47 | 60 | |
| 48 | −def parameter_count(attrs: dict[str, Any] | None) -> float | None: | |
| 49 | − v = (attrs or {}).get("parameter_count") | |
| 50 | − if isinstance(v, (int, float)) and not isinstance(v, bool) and v > 0: | |
| 61 | +def hardware_memory_options(attrs: dict[str, Any] | None) -> list[float]: | |
| 62 | + v = (attrs or {}).get("memory_gb") | |
| 63 | + if isinstance(v, list): | |
| 64 | + return sorted(float(x) for x in v if isinstance(x, (int, float)) and not isinstance(x, bool)) | |
| 65 | + m = hardware_memory_gb(attrs) | |
| 66 | + return [m] if m is not None else [] | |
| 67 | + | |
| 68 | + | |
| 69 | +def _num(v: Any) -> float | None: | |
| 70 | + if isinstance(v, bool): | |
| 71 | + return None | |
| 72 | + if isinstance(v, (int, float)): | |
| 51 | 73 | return float(v) |
| 52 | 74 | if isinstance(v, str): |
| 53 | 75 | try: |
| 54 | − f = float(v) | |
| 55 | − return f if f > 0 else None | |
| 76 | + return float(v.replace(",", "")) | |
| 56 | 77 | except ValueError: |
| 57 | 78 | return None |
| 58 | 79 | return None |
| 59 | 80 | |
| 60 | 81 | |
| 82 | +def parameter_count(attrs: dict[str, Any] | None) -> float | None: | |
| 83 | + v = _num((attrs or {}).get("parameter_count")) | |
| 84 | + return v if v is not None and v > 0 else None | |
| 85 | + | |
| 86 | + | |
| 87 | +def file_size_gb(attrs: dict[str, Any] | None) -> float | None: | |
| 88 | + """Observed weights size (Hugging Face repo file total) when recorded.""" | |
| 89 | + v = _num((attrs or {}).get("file_size_gb")) | |
| 90 | + return v if v is not None and v > 0 else None | |
| 91 | + | |
| 92 | + | |
| 93 | +def architecture(attrs: dict[str, Any] | None) -> dict[str, float] | None: | |
| 94 | + """{layers, kv_heads, head_dim} from Hugging Face-style config keys when all three can be derived, else None.""" | |
| 95 | + a = attrs or {} | |
| 96 | + layers = _num(a.get("num_hidden_layers") or a.get("n_layer") or a.get("num_layers")) | |
| 97 | + heads = _num(a.get("num_attention_heads") or a.get("n_head")) | |
| 98 | + kv_heads = _num(a.get("num_key_value_heads")) or heads | |
| 99 | + head_dim = _num(a.get("head_dim")) | |
| 100 | + hidden = _num(a.get("hidden_size") or a.get("n_embd")) | |
| 101 | + if head_dim is None and hidden and heads: | |
| 102 | + head_dim = hidden / heads | |
| 103 | + if layers and kv_heads and head_dim: | |
| 104 | + return {"layers": layers, "kv_heads": kv_heads, "head_dim": head_dim} | |
| 105 | + return None | |
| 106 | + | |
| 107 | + | |
| 108 | +def kv_cache_gb(context: int, *, batch: int = 1, quant: str = "4bit", arch: dict[str, float] | None = None) -> tuple[float, str]: | |
| 109 | + """(GB, method). `method` is 'architecture' when computed from layers × kv_heads × head_dim, else 'heuristic'.""" | |
| 110 | + ctx = max(0.0, float(context)) | |
| 111 | + b = max(1, int(batch)) | |
| 112 | + if arch: | |
| 113 | + bytes_ = KV_BYTES.get(quant, 2.0) | |
| 114 | + gb = 2.0 * arch["layers"] * arch["kv_heads"] * arch["head_dim"] * bytes_ * ctx * b / 1e9 | |
| 115 | + return round(gb, 3), "architecture" | |
| 116 | + return round(KV_GB_PER_8K * ctx / 8192.0 * b, 3), "heuristic" | |
| 117 | + | |
| 118 | + | |
| 61 | 119 | def fit(parameter_count_: float, memory_gb: float, quant: str = "4bit", context: int = 8192) -> dict[str, Any]: |
| 62 | 120 | est = estimate_memory_gb(parameter_count_, quant, context) |
| 63 | 121 | headroom = round(memory_gb - RESERVED_GB - est, 2) |
| 64 | − return {"quantization": quant, "estimated_memory_gb": est, "fits": headroom >= 0, "headroom_gb": headroom, | |
| 122 | + return {"quantization": quant, "estimated_memory_gb": est, "fits": headroom >= 0, "headroom_gb": headroom, "estimated": True, | |
| 65 | 123 | "note": f"estimated: {parameter_count_ / 1e9:.1f}B params × {BYTES_PER_PARAM.get(quant, 0.5)} B × 1.15 + KV cache for {context} tokens"} |
| 66 | 124 | |
| 67 | 125 | |
| 68 | −__all__ = ["ASSUMPTIONS", "BYTES_PER_PARAM", "estimate_memory_gb", "fit", "hardware_memory_gb", "parameter_count"] | |
| 126 | +def fit_detailed(attrs: dict[str, Any] | None, memory_gb: float, *, quant: str = "4bit", context: int = 8192, batch: int = 1, gpu_count: int = 1, | |
| 127 | + observed_size_gb: float | None = None) -> dict[str, Any] | None: | |
| 128 | + """Full breakdown (weights / KV cache / overhead / headroom). `observed_size_gb` (artifact file size) replaces the parameter-based weights | |
| 129 | + estimate when given. Returns None when neither parameters nor an observed size are known — nothing is estimated from thin air.""" | |
| 130 | + quant = normalize_quant(quant) | |
| 131 | + params = parameter_count(attrs) | |
| 132 | + observed = observed_size_gb if observed_size_gb else None | |
| 133 | + if params is None and observed is None: | |
| 134 | + return None | |
| 135 | + if observed is not None: | |
| 136 | + weights_raw = observed | |
| 137 | + weights_source = "observed" | |
| 138 | + else: | |
| 139 | + assert params is not None | |
| 140 | + weights_raw = params * BYTES_PER_PARAM[quant] / 1e9 | |
| 141 | + weights_source = "estimated" | |
| 142 | + overhead = round(weights_raw * (OVERHEAD - 1), 2) | |
| 143 | + kv, kv_method = kv_cache_gb(context, batch=batch, quant=quant, arch=architecture(attrs)) | |
| 144 | + total = round(weights_raw + overhead + kv, 2) | |
| 145 | + total_memory = float(memory_gb) * max(1, int(gpu_count)) | |
| 146 | + headroom = round(total_memory - RESERVED_GB - total, 2) | |
| 147 | + out: dict[str, Any] = { | |
| 148 | + "quantization": quant, "estimated": True, "fits": headroom >= 0, "estimated_memory_gb": total, "headroom_gb": headroom, | |
| 149 | + "breakdown": {"weights_gb": round(weights_raw, 2), "weights_source": weights_source, "overhead_gb": overhead, "kv_cache_gb": kv, "kv_cache_method": kv_method, | |
| 150 | + "reserved_gb": RESERVED_GB, "context": int(context), "batch": int(batch)}, | |
| 151 | + "device": {"memory_gb": float(memory_gb), "gpu_count": int(gpu_count), "total_memory_gb": total_memory}, | |
| 152 | + "note": (f"{'observed' if weights_source == 'observed' else 'estimated'} weights {weights_raw:.1f} GB + 15% overhead + KV cache {kv:.2f} GB ({kv_method}) " | |
| 153 | + f"for {context} tokens × batch {batch}; {RESERVED_GB:.0f} GB reserved for the OS/framework"), | |
| 154 | + } | |
| 155 | + if params is not None: | |
| 156 | + out["parameter_count"] = params | |
| 157 | + if gpu_count > 1: | |
| 158 | + out["multi_gpu_note"] = "Device memories summed; interconnect bandwidth, tensor-parallel replication and pipeline bubbles are not modelled." | |
| 159 | + return out | |
| 160 | + | |
| 161 | + | |
| 162 | +__all__ = ["ASSUMPTIONS", "BYTES_PER_PARAM", "KV_BYTES", "RESERVED_GB", "architecture", "estimate_memory_gb", "file_size_gb", "fit", "fit_detailed", | |
| 163 | + "hardware_memory_gb", "hardware_memory_options", "kv_cache_gb", "normalize_quant", "parameter_count"] | |
added
src/aiatlas/services/pareto.py
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +"""Pareto frontier (maximise y, minimise x) over labelled points. Pure Python, deterministic. | |
| 2 | + | |
| 3 | +A point is *Pareto-efficient* when no other point has both a lower-or-equal x and a higher-or-equal y with at least one strict inequality. | |
| 4 | +Exact ties (same x and same y) are kept together on the frontier — we never break a tie by choosing one model over another.""" | |
| 5 | +from __future__ import annotations | |
| 6 | + | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | + | |
| 10 | +def pareto_frontier(points: list[dict[str, Any]], *, x: str = "x", y: str = "y", key: str = "id", maximize_y: bool = True) -> list[Any]: | |
| 11 | + """Return the `key`s of the Pareto-efficient points, ordered by increasing x. Points with a missing x or y are ignored.""" | |
| 12 | + valid = [p for p in points if p.get(x) is not None and p.get(y) is not None] | |
| 13 | + if not valid: | |
| 14 | + return [] | |
| 15 | + sign = 1.0 if maximize_y else -1.0 | |
| 16 | + # sort by x ascending, then by y descending (best first) so a sweep with the running best y finds the frontier | |
| 17 | + ordered = sorted(valid, key=lambda p: (float(p[x]), -sign * float(p[y]))) | |
| 18 | + frontier: list[Any] = [] | |
| 19 | + best_y: float | None = None | |
| 20 | + best_x: float | None = None | |
| 21 | + for p in ordered: | |
| 22 | + px, py = float(p[x]), sign * float(p[y]) | |
| 23 | + if best_y is None or py > best_y: | |
| 24 | + frontier.append(p[key]) | |
| 25 | + best_y, best_x = py, px | |
| 26 | + elif py == best_y and best_x is not None and px == best_x: | |
| 27 | + frontier.append(p[key]) # exact tie: keep both | |
| 28 | + return frontier | |
| 29 | + | |
| 30 | + | |
| 31 | +def is_dominated(p: dict[str, Any], others: list[dict[str, Any]], *, x: str = "x", y: str = "y", maximize_y: bool = True) -> bool: | |
| 32 | + """True when some other point is at least as good on both axes and strictly better on one.""" | |
| 33 | + sign = 1.0 if maximize_y else -1.0 | |
| 34 | + px, py = float(p[x]), sign * float(p[y]) | |
| 35 | + for o in others: | |
| 36 | + if o is p or o.get(x) is None or o.get(y) is None: | |
| 37 | + continue | |
| 38 | + ox, oy = float(o[x]), sign * float(o[y]) | |
| 39 | + if ox <= px and oy >= py and (ox < px or oy > py): | |
| 40 | + return True | |
| 41 | + return False | |
| 42 | + | |
| 43 | + | |
| 44 | +__all__ = ["is_dominated", "pareto_frontier"] | |
modified
src/aiatlas/services/search.py
+369 −61
@@ -1,28 +1,49 @@ | ||
| 1 | −"""Search: Postgres FTS + trigram (+ pgvector when embeddings exist) and a deterministic natural-language → filter compiler. | |
| 1 | +"""Search: Postgres FTS + trigram (+ pgvector when embeddings exist) and a deterministic natural-language → filter compiler (v2). | |
| 2 | 2 | |
| 3 | − "open models released in 2026 with more than 100B parameters and 128k context" | |
| 4 | − → {entity_type: model, openness: open, year_from: 2026, params_min: 1e11, context_min: 128000} | |
| 5 | −""" | |
| 3 | + "open models over 100B released this year" → type model · openness open · params ≥ 1e11 · year_from = year_to = <this year> | |
| 4 | + "cheapest models with 1M context" → type model · context ≥ 1 000 000 · sort cheapest | |
| 5 | + "reasoning models under $1/M tokens" → type model · reasoning · max_output_price 1.0 | |
| 6 | + "models that fit in 64GB" → type model · memory_gb 64 (→ ESTIMATED params bound at 4-bit) | |
| 7 | + "Anthropic models released since 2025" → type model · organization Anthropic (verified by the API against org aliases) · year_from 2025 | |
| 8 | + "open vision models with Apache license" → type model · openness open · modality image · license Apache-2.0 | |
| 9 | + "papers introducing MoE models" → type paper · residual "introducing MoE" | |
| 10 | + | |
| 11 | +Every recognised fragment is listed in `compiled` (filter, label, value, source_span) so the UI can show "Compiled as …"; the rest of the | |
| 12 | +text is `residual` (used for full-text search) and its words are echoed in `unrecognised`.""" | |
| 6 | 13 | from __future__ import annotations |
| 7 | 14 | |
| 8 | 15 | import re |
| 9 | 16 | from dataclasses import dataclass, field |
| 17 | +from datetime import UTC, datetime | |
| 10 | 18 | from typing import Any |
| 11 | 19 | |
| 12 | 20 | from sqlalchemy.ext.asyncio import AsyncConnection |
| 13 | 21 | |
| 14 | 22 | from aiatlas.db import fetch_all |
| 23 | +from aiatlas.ontology.licenses import normalize_license | |
| 15 | 24 | from aiatlas.sdk.extract.numbers import parse_context_length, parse_param_count |
| 25 | +from aiatlas.services import hardware_fit as hf | |
| 16 | 26 | |
| 17 | 27 | TYPE_WORDS = { |
| 18 | − "model": ("model", "models", "llm", "llms", "language model"), "company": ("company", "companies", "startup", "startups", "lab", "labs", "organization"), | |
| 19 | − "paper": ("paper", "papers", "research", "publication", "preprint", "arxiv"), "provider": ("provider", "providers", "inference provider", "api provider"), | |
| 20 | − "benchmark": ("benchmark", "benchmarks", "leaderboard", "eval", "evals"), "hardware": ("gpu", "gpus", "chip", "chips", "hardware", "accelerator", "npu", "tpu"), | |
| 21 | − "framework": ("framework", "frameworks", "library", "libraries", "runtime", "runtimes"), "dataset": ("dataset", "datasets"), | |
| 22 | − "tool": ("tool", "tools", "agent", "agents", "app", "application"), "repository": ("repo", "repos", "repository", "repositories"), | |
| 28 | + "model": ("model", "models", "llm", "llms", "language model", "language models"), | |
| 29 | + "company": ("company", "companies", "startup", "startups", "lab", "labs", "organization", "organizations", "organisation"), | |
| 30 | + "paper": ("paper", "papers", "research", "publication", "publications", "preprint", "preprints", "arxiv"), | |
| 31 | + "provider": ("provider", "providers", "inference provider", "api provider", "inference providers"), | |
| 32 | + "benchmark": ("benchmark", "benchmarks", "leaderboard", "leaderboards", "eval", "evals"), | |
| 33 | + "hardware": ("gpu", "gpus", "chip", "chips", "hardware", "accelerator", "accelerators", "npu", "tpu"), | |
| 34 | + "framework": ("framework", "frameworks", "library", "libraries", "runtime", "runtimes"), | |
| 35 | + "dataset": ("dataset", "datasets"), | |
| 36 | + "tool": ("tool", "tools", "agent", "agents", "app", "application"), | |
| 37 | + "repository": ("repo", "repos", "repository", "repositories"), | |
| 23 | 38 | } |
| 24 | −MODALITY_WORDS = {"vision": "image", "multimodal": "multimodal", "image": "image", "audio": "audio", "speech": "audio", "video": "video", "code": "code", | |
| 25 | − "coding": "code", "embedding": "embedding", "embeddings": "embedding"} | |
| 39 | +MODALITY_WORDS = {"vision": "image", "multimodal": "image", "image": "image", "images": "image", "audio": "audio", "speech": "audio", "voice": "audio", | |
| 40 | + "video": "video", "embedding": "embedding", "embeddings": "embedding"} | |
| 41 | +LICENSE_WORDS = {"apache": "Apache-2.0", "apache 2.0": "Apache-2.0", "apache-2.0": "Apache-2.0", "mit": "MIT", "mit license": "MIT", "bsd": "BSD-3-Clause", | |
| 42 | + "cc-by": "CC-BY-4.0", "cc by": "CC-BY-4.0", "gpl": "GPL-3.0", "llama license": "Llama-3.1-Community", "gemma license": "Gemma-Terms"} | |
| 43 | +SORT_WORDS = {"cheapest": "cheapest", "cheap": "cheapest", "lowest price": "cheapest", "newest": "newest", "latest": "newest", "most recent": "newest", | |
| 44 | + "recent": "newest", "largest": "largest", "biggest": "largest", "smallest": "smallest", "best": "best"} | |
| 45 | +STOP = {"the", "a", "an", "with", "and", "or", "of", "for", "that", "which", "in", "on", "to", "by", "from", "at", "my", "me", "i", "are", "is", "show", "find", "list", "all", "any"} | |
| 46 | +_SIZE = r"(\d+(?:\.\d+)?)\s*([bBmMtT])\b(?:\s*param(?:eter)?s?)?" | |
| 26 | 47 | |
| 27 | 48 | |
| 28 | 49 | @dataclass |
@@ -38,90 +59,365 @@ class Query: | ||
| 38 | 59 | modalities: list[str] = field(default_factory=list) |
| 39 | 60 | organization: str | None = None |
| 40 | 61 | filters: dict[str, Any] = field(default_factory=dict) |
| 62 | + # v2 | |
| 63 | + reasoning: bool | None = None | |
| 64 | + max_input_price: float | None = None | |
| 65 | + max_output_price: float | None = None | |
| 66 | + memory_gb: float | None = None | |
| 67 | + license_key: str | None = None | |
| 68 | + commercial_use: bool | None = None | |
| 69 | + days_back: int | None = None | |
| 70 | + benchmark: str | None = None | |
| 71 | + provider: str | None = None | |
| 72 | + sort: str | None = None | |
| 73 | + compiled: list[dict[str, Any]] = field(default_factory=list) | |
| 74 | + residual: str = "" | |
| 75 | + unrecognised: list[str] = field(default_factory=list) | |
| 41 | 76 | |
| 42 | 77 | def as_dict(self) -> dict[str, Any]: |
| 43 | − return {k: v for k, v in self.__dict__.items() if v not in (None, "", [], {})} | |
| 78 | + out = {k: v for k, v in self.__dict__.items() if v not in (None, "", [], {}) and k not in ("compiled", "residual", "unrecognised")} | |
| 79 | + out["compiled"] = self.compiled | |
| 80 | + out["residual"] = self.residual | |
| 81 | + out["unrecognised"] = self.unrecognised | |
| 82 | + out["filters"] = {**self.filters, "residual": self.residual} | |
| 83 | + return out | |
| 84 | + | |
| 85 | + @property | |
| 86 | + def has_structure(self) -> bool: | |
| 87 | + return any([self.entity_type, self.openness, self.year_from, self.year_to, self.params_min, self.params_max, self.context_min, self.modalities, | |
| 88 | + self.organization, self.reasoning, self.max_input_price, self.max_output_price, self.memory_gb, self.license_key, self.commercial_use, | |
| 89 | + self.days_back, self.benchmark, self.provider]) | |
| 90 | + | |
| 91 | + | |
| 92 | +class _Compiler: | |
| 93 | + def __init__(self, text: str): | |
| 94 | + self.text = text | |
| 95 | + self.low = text.lower() | |
| 96 | + self.consumed: list[tuple[int, int]] = [] | |
| 97 | + self.q = Query(text=text) | |
| 98 | + | |
| 99 | + def take(self, m: re.Match[str], filter_: str, label: str, value: Any) -> None: | |
| 100 | + self.consumed.append((m.start(), m.end())) | |
| 101 | + self.q.compiled.append({"filter": filter_, "label": label, "value": value, "source_span": self.text[m.start():m.end()]}) | |
| 102 | + | |
| 103 | + def search(self, pattern: str) -> re.Match[str] | None: | |
| 104 | + for m in re.finditer(pattern, self.low, flags=re.IGNORECASE): | |
| 105 | + if not any(s <= m.start() < e or s < m.end() <= e for s, e in self.consumed): | |
| 106 | + return m | |
| 107 | + return None | |
| 108 | + | |
| 109 | + def residual(self) -> str: | |
| 110 | + chars = list(self.text) | |
| 111 | + for s, e in self.consumed: | |
| 112 | + for i in range(s, e): | |
| 113 | + chars[i] = " " | |
| 114 | + return " ".join("".join(chars).split()) | |
| 44 | 115 | |
| 45 | 116 | |
| 46 | 117 | def compile_query(q: str) -> Query: |
| 47 | − s = q.strip() | |
| 48 | − low = f" {s.lower()} " | |
| 49 | − out = Query(text=s) | |
| 50 | − for etype, words in TYPE_WORDS.items(): | |
| 51 | − if any(f" {w} " in low for w in words): | |
| 52 | − out.entity_type = etype | |
| 53 | − break | |
| 54 | − if re.search(r"\b(open[- ]?(weight|weights|source)|open models?)\b", low): | |
| 118 | + c = _Compiler(q.strip()) | |
| 119 | + out = c.q | |
| 120 | + year_now = datetime.now(UTC).year | |
| 121 | + | |
| 122 | + # --- explicit "benchmark <name>" / "provider <name>" | |
| 123 | + m = c.search(r"\bbenchmark\s+([a-z0-9][\w.\- ]{1,40}?)(?=\s+(?:leaderboard|results|scores|models)\b|$)") | |
| 124 | + if m: | |
| 125 | + out.benchmark = m.group(1).strip() | |
| 126 | + out.entity_type = "benchmark" | |
| 127 | + c.take(m, "benchmark", "Benchmark", out.benchmark) | |
| 128 | + m = c.search(r"\bprovider\s+([a-z0-9][\w.\- ]{1,40}?)(?=\s+(?:prices?|models)\b|$)") | |
| 129 | + if m: | |
| 130 | + out.provider = m.group(1).strip() | |
| 131 | + out.entity_type = "provider" | |
| 132 | + c.take(m, "provider", "Provider", out.provider) | |
| 133 | + | |
| 134 | + # --- price bounds | |
| 135 | + m = c.search(r"(?:under|below|cheaper than|less than|at most|<|≤)\s*\$?\s*(\d+(?:\.\d+)?)\s*(?:usd)?\s*(?:/|per)\s*(?:1\s*)?m(?:illion)?(?:\s*(?:tok(?:ens?)?|output(?:\s*tokens?)?|input(?:\s*tokens?)?))?") | |
| 136 | + if m: | |
| 137 | + which = "input" if "input" in m.group(0) else "output" | |
| 138 | + val = float(m.group(1)) | |
| 139 | + if which == "input": | |
| 140 | + out.max_input_price = val | |
| 141 | + else: | |
| 142 | + out.max_output_price = val | |
| 143 | + c.take(m, f"max_{which}_price", f"Cheapest {which} price ≤ ${val:g} / 1M tokens", val) | |
| 144 | + m = c.search(r"\$\s*(\d+(?:\.\d+)?)\s*(?:/|per)\s*(?:1\s*)?m(?:illion)?\b(?:\s*tok(?:ens?)?)?") | |
| 145 | + if m and out.max_output_price is None and out.max_input_price is None: | |
| 146 | + out.max_output_price = float(m.group(1)) | |
| 147 | + c.take(m, "max_output_price", f"Cheapest output price ≤ ${out.max_output_price:g} / 1M tokens", out.max_output_price) | |
| 148 | + | |
| 149 | + # --- memory / hardware fit | |
| 150 | + m = c.search(r"(?:fits?\s+(?:in|on)|runs?\s+on|run\s+locally\s+on|on\s+my)\s*(?:a\s+|an\s+)?(?:mac(?:book)?(?:\s+\w+)?\s+with\s+)?(\d+(?:\.\d+)?)\s*gb\b(?:\s*(?:of\s+)?(?:ram|memory|vram|unified memory))?") | |
| 151 | + if not m: | |
| 152 | + m = c.search(r"\b(\d+(?:\.\d+)?)\s*gb\s*(?:of\s+)?(?:ram|memory|vram)\b") | |
| 153 | + if m: | |
| 154 | + out.memory_gb = float(m.group(1)) | |
| 155 | + c.take(m, "memory_gb", f"Fits in {out.memory_gb:g} GB (estimated at 4-bit, 8K context)", out.memory_gb) | |
| 156 | + elif c.search(r"\bon my mac\b"): | |
| 157 | + m2 = c.search(r"\bon my mac\b") | |
| 158 | + assert m2 is not None | |
| 55 | 159 | out.openness = "open" |
| 56 | − elif re.search(r"\b(proprietary|closed)\b", low): | |
| 57 | − out.openness = "proprietary" | |
| 58 | − m = re.search(r"\b(?:released|launched|published|from|since|after|in)\s+(20\d\d)\b", low) | |
| 160 | + c.take(m2, "openness", "Runs locally → open weights", "open") | |
| 161 | + | |
| 162 | + # --- context window | |
| 163 | + m = c.search(r"(?:context(?:\s+window)?\s*(?:of\s+)?(?:>=|≥|>|over|above|at least|min(?:imum)?)?\s*(\d+(?:\.\d+)?\s*[kKmM])\b(?:\s*tokens?)?)") | |
| 164 | + if not m: | |
| 165 | + m = c.search(r"(?:with\s+)?(\d+(?:\.\d+)?\s*[kKmM])\b\s*(?:\+\s*)?(?:tokens?\s+)?(?:of\s+)?context(?:\s+window)?") | |
| 166 | + if not m: | |
| 167 | + m = c.search(r"\blong[\s-]context\b") | |
| 168 | + if m: | |
| 169 | + out.context_min = 128_000 | |
| 170 | + c.take(m, "context_min", "Context ≥ 128K tokens", 128_000) | |
| 171 | + m = None | |
| 172 | + if m: | |
| 173 | + out.context_min = parse_context_length(m.group(1)) | |
| 174 | + c.take(m, "context_min", f"Context ≥ {out.context_min:,} tokens", out.context_min) | |
| 175 | + | |
| 176 | + # --- parameter counts | |
| 177 | + m = c.search(r"\bbetween\s+" + _SIZE + r"\s+and\s+" + _SIZE) | |
| 178 | + if m: | |
| 179 | + out.params_min = parse_param_count(m.group(1) + m.group(2) + " params") | |
| 180 | + out.params_max = parse_param_count(m.group(3) + m.group(4) + " params") | |
| 181 | + c.take(m, "params_range", f"Parameters between {m.group(1)}{m.group(2).upper()} and {m.group(3)}{m.group(4).upper()}", [out.params_min, out.params_max]) | |
| 182 | + m = c.search(r"(?:more than|over|above|at least|>=|≥|>|larger than|bigger than)\s*" + _SIZE) | |
| 183 | + if m: | |
| 184 | + out.params_min = parse_param_count(m.group(1) + m.group(2) + " params") | |
| 185 | + c.take(m, "params_min", f"Parameters ≥ {m.group(1)}{m.group(2).upper()}", out.params_min) | |
| 186 | + m = c.search(r"(?:less than|under|below|at most|<=|≤|<|smaller than)\s*" + _SIZE) | |
| 187 | + if m: | |
| 188 | + out.params_max = parse_param_count(m.group(1) + m.group(2) + " params") | |
| 189 | + c.take(m, "params_max", f"Parameters ≤ {m.group(1)}{m.group(2).upper()}", out.params_max) | |
| 190 | + m = c.search(r"\b" + _SIZE + r"\s*(?:models?|parameters?)\b") | |
| 191 | + if m and out.params_min is None and out.params_max is None: | |
| 192 | + n = parse_param_count(m.group(1) + m.group(2) + " params") | |
| 193 | + if n: | |
| 194 | + out.params_min, out.params_max = int(n * 0.85), int(n * 1.15) | |
| 195 | + c.take(m, "params_about", f"About {m.group(1)}{m.group(2).upper()} parameters (±15%)", n) | |
| 196 | + | |
| 197 | + # --- dates | |
| 198 | + m = c.search(r"\b(?:(?:released|launched|published|announced)\s+)?(?:in\s+the\s+)?(?:last|past)\s+(\d+)\s+(day|week|month|year)s?\b") | |
| 199 | + if m: | |
| 200 | + n, unit = int(m.group(1)), m.group(2) | |
| 201 | + out.days_back = n * {"day": 1, "week": 7, "month": 30, "year": 365}[unit] | |
| 202 | + c.take(m, "days_back", f"Released in the last {n} {unit}{'s' if n > 1 else ''}", out.days_back) | |
| 203 | + m = c.search(r"\b(?:released|launched|published|announced)?\s*(?:this|the current)\s+year\b") | |
| 204 | + if m: | |
| 205 | + out.year_from = out.year_to = year_now | |
| 206 | + c.take(m, "year", f"Released in {year_now}", year_now) | |
| 207 | + m = c.search(r"\b(?:released|launched|published|announced)?\s*(?:last|previous)\s+year\b") | |
| 208 | + if m: | |
| 209 | + out.year_from = out.year_to = year_now - 1 | |
| 210 | + c.take(m, "year", f"Released in {year_now - 1}", year_now - 1) | |
| 211 | + m = c.search(r"\b(?:(?:released|launched|published|announced)\s+)?(?:since|after|from)\s+(20\d\d)\b") | |
| 59 | 212 | if m: |
| 60 | 213 | out.year_from = int(m.group(1)) |
| 61 | − if re.search(r"\bin\s+" + m.group(1), low): | |
| 62 | − out.year_to = out.year_from | |
| 63 | − m = re.search(r"\b(?:before|until)\s+(20\d\d)\b", low) | |
| 214 | + c.take(m, "year_from", f"Released since {out.year_from}", out.year_from) | |
| 215 | + m = c.search(r"\b(?:(?:released|launched|published|announced)\s+)?(?:before|until|up to)\s+(20\d\d)\b") | |
| 64 | 216 | if m: |
| 65 | 217 | out.year_to = int(m.group(1)) |
| 66 | − m = re.search(r"(?:more than|over|above|>|at least|≥)\s*(\d+(?:\.\d+)?\s*[bBmMtT])\b(?:\s*param)?", s) | |
| 218 | + c.take(m, "year_to", f"Released before {out.year_to}", out.year_to) | |
| 219 | + m = c.search(r"\b(?:(?:released|launched|published|announced)\s+)?in\s+(20\d\d)\b") | |
| 67 | 220 | if m: |
| 68 | − out.params_min = parse_param_count(m.group(1) + " params") | |
| 69 | − m = re.search(r"(?:less than|under|below|<|at most|≤)\s*(\d+(?:\.\d+)?\s*[bBmMtT])\b", s) | |
| 221 | + out.year_from = out.year_to = int(m.group(1)) | |
| 222 | + c.take(m, "year", f"Released in {m.group(1)}", int(m.group(1))) | |
| 223 | + m = c.search(r"\b(20\d\d)\b") | |
| 224 | + if m and out.year_from is None and out.year_to is None: | |
| 225 | + out.year_from = out.year_to = int(m.group(1)) | |
| 226 | + c.take(m, "year", f"Released in {m.group(1)}", int(m.group(1))) | |
| 227 | + | |
| 228 | + # --- licence | |
| 229 | + m = c.search(r"\b(apache(?:[\s-]*2(?:\.0)?)?|mit|bsd|cc[\s-]by|gpl|llama|gemma)\s*(?:licen[cs]e[d]?)\b") | |
| 70 | 230 | if m: |
| 71 | − out.params_max = parse_param_count(m.group(1) + " params") | |
| 72 | − m = re.search(r"(?:context|ctx)\s*(?:window\s*)?(?:>|over|above|of at least|at least|≥)?\s*(\d+\s*[kKmM])", s) or \ | |
| 73 | − re.search(r"(\d+\s*[kKmM])\s*(?:\+\s*)?(?:token\s*)?context", s) | |
| 231 | + raw = m.group(1).strip() | |
| 232 | + key = normalize_license(raw) or LICENSE_WORDS.get(raw.lower()) | |
| 233 | + if key: | |
| 234 | + out.license_key = key | |
| 235 | + c.take(m, "license", f"Licence {key}", key) | |
| 236 | + m = c.search(r"\b(?:for\s+)?commercial(?:\s+use|ly usable|-use)?\b(?:\s+allowed|\s+ok)?") | |
| 74 | 237 | if m: |
| 75 | − out.context_min = parse_context_length(m.group(1)) | |
| 238 | + out.commercial_use = True | |
| 239 | + c.take(m, "commercial_use", "Licence allows commercial use", True) | |
| 240 | + | |
| 241 | + # --- openness | |
| 242 | + m = c.search(r"\b(open[- ]?(?:weights?|source|sourced)|open)\b(?=\s+(?:models?|llms?|vision|reasoning|coding|multimodal|\w+\s+models?)\b)") or c.search(r"\bopen[- ]?(?:weights?|source|sourced)\b") | |
| 243 | + if m: | |
| 244 | + out.openness = "open" | |
| 245 | + c.take(m, "openness", "Open weights / open source", "open") | |
| 246 | + else: | |
| 247 | + m = c.search(r"\b(proprietary|closed(?:[- ]source)?|api[- ]only)\b") | |
| 248 | + if m: | |
| 249 | + out.openness = "proprietary" | |
| 250 | + c.take(m, "openness", "Proprietary (API only)", "proprietary") | |
| 251 | + | |
| 252 | + # --- reasoning | |
| 253 | + m = c.search(r"\b(reasoning|thinking)\b(?=\s+(?:models?|llms?)\b)") or c.search(r"\bwith\s+(reasoning|thinking)\b") | |
| 254 | + if m: | |
| 255 | + out.reasoning = True | |
| 256 | + c.take(m, "reasoning", "Reasoning / thinking models", True) | |
| 257 | + | |
| 258 | + # --- modalities | |
| 76 | 259 | for w, mod in MODALITY_WORDS.items(): |
| 77 | − if f" {w} " in low and mod not in out.modalities: | |
| 260 | + m = c.search(rf"\b{w}\b") | |
| 261 | + if m and mod not in out.modalities: | |
| 78 | 262 | out.modalities.append(mod) |
| 79 | − m = re.search(r"\b(?:by|from)\s+([A-Z][\w.&-]+(?:\s+[A-Z][\w.&-]+)?)", s) | |
| 80 | − if m: | |
| 81 | − out.organization = m.group(1) | |
| 82 | − # residual free-text (remove the structured bits) | |
| 83 | − residual = re.sub(r"\b(more than|over|above|less than|under|below|at least|at most|released|launched|published|since|after|before|until|with|and|in|from|by|the|a|an|context|window|params?|parameters)\b", " ", s, flags=re.IGNORECASE) | |
| 84 | − residual = re.sub(r"\d+(?:\.\d+)?\s*[bBmMkKtT]\b|\b20\d\d\b|[<>≤≥]", " ", residual) | |
| 85 | − for words in TYPE_WORDS.values(): | |
| 86 | − for w in words: | |
| 87 | − residual = re.sub(rf"\b{re.escape(w)}\b", " ", residual, flags=re.IGNORECASE) | |
| 88 | − residual = re.sub(r"\b(open[- ]?(weight|weights|source)|proprietary|closed)\b", " ", residual, flags=re.IGNORECASE) | |
| 89 | − out.filters["residual"] = " ".join(residual.split()) | |
| 263 | + c.take(m, "modality", f"Modality: {mod}", mod) | |
| 264 | + | |
| 265 | + # --- entity type: the earliest surviving type word wins ("papers introducing MoE models" → paper) | |
| 266 | + if out.entity_type is None: | |
| 267 | + best: tuple[int, str, re.Match[str]] | None = None | |
| 268 | + for etype, words in TYPE_WORDS.items(): | |
| 269 | + for w in sorted(words, key=len, reverse=True): | |
| 270 | + m = c.search(rf"\b{re.escape(w)}\b") | |
| 271 | + if m and (best is None or m.start() < best[0]): | |
| 272 | + best = (m.start(), etype, m) | |
| 273 | + break | |
| 274 | + if best: | |
| 275 | + out.entity_type = best[1] | |
| 276 | + c.take(best[2], "entity_type", f"Type: {best[1]}", best[1]) | |
| 277 | + if out.entity_type is None and (out.openness or out.params_min or out.params_max or out.context_min or out.reasoning or out.memory_gb or out.max_output_price or out.max_input_price): | |
| 278 | + out.entity_type = "model" | |
| 279 | + out.compiled.append({"filter": "entity_type", "label": "Type: model (implied)", "value": "model", "source_span": None}) | |
| 280 | + | |
| 281 | + # --- sort hints | |
| 282 | + for w, s in sorted(SORT_WORDS.items(), key=lambda kv: -len(kv[0])): | |
| 283 | + m = c.search(rf"\b{re.escape(w)}\b") | |
| 284 | + if m: | |
| 285 | + out.sort = s | |
| 286 | + c.take(m, "sort", f"Sort: {s}", s) | |
| 287 | + break | |
| 288 | + | |
| 289 | + # --- organization: "by <Org>" / "from <Org>" / "<Org> models" (verified against the organization alias table by the API layer) | |
| 290 | + m = c.search(r"\b(?:by|from)\s+([A-Za-z][\w.&-]+(?:\s+[A-Z][\w.&-]+)?)") | |
| 291 | + if m and m.group(1).lower() not in STOP and m.group(1).lower() not in {w for ws in TYPE_WORDS.values() for w in ws}: | |
| 292 | + out.organization = q[m.start(1):m.end(1)] | |
| 293 | + c.take(m, "organization", f"Organization: {out.organization}", out.organization) | |
| 294 | + else: | |
| 295 | + # "<Org> models …" only at the very start of the query — a capitalised word in the middle ("introducing MoE models") is free text | |
| 296 | + m = re.match(r"\s*(?:(?:all|the|show|list|find)\s+)?([A-Z][\w.&-]*(?:\s+[A-Z][\w.&-]*)?)\s+(?:models?|llms?|papers?|releases?)\b", q) | |
| 297 | + if m and not any(s <= m.start(1) < e for s, e in c.consumed) and m.group(1).lower() not in STOP and m.group(1).lower() not in {w for ws in TYPE_WORDS.values() for w in ws} \ | |
| 298 | + and m.group(1).lower() not in ("open", "reasoning", "vision", "proprietary", "multimodal", "cheapest", "newest", "largest", "best", "new", "moe", "small", "large"): | |
| 299 | + out.organization = m.group(1) | |
| 300 | + c.consumed.append((m.start(1), m.end(1))) | |
| 301 | + out.compiled.append({"filter": "organization", "label": f"Organization: {out.organization}", "value": out.organization, "source_span": m.group(1)}) | |
| 302 | + | |
| 303 | + residual = c.residual() | |
| 304 | + residual = re.sub(r"\b(" + "|".join(sorted(STOP, key=len, reverse=True)) + r")\b", " ", residual, flags=re.IGNORECASE) | |
| 305 | + residual = re.sub(r"[<>≤≥$]", " ", residual) | |
| 306 | + out.residual = " ".join(residual.split()) | |
| 307 | + out.filters["residual"] = out.residual | |
| 308 | + out.unrecognised = [w for w in re.findall(r"[\w.\-]+", out.residual) if len(w) > 1] if out.has_structure else [] | |
| 90 | 309 | return out |
| 91 | 310 | |
| 92 | 311 | |
| 93 | −async def search_entities(conn: AsyncConnection, q: Query, *, limit: int = 30, offset: int = 0, embedding: list[float] | None = None) -> list[dict[str, Any]]: | |
| 312 | +def params_bound_from_memory(memory_gb: float, quant: str = "4bit") -> int: | |
| 313 | + """ESTIMATED largest parameter count that fits `memory_gb` at 8K context (inverse of services.hardware_fit).""" | |
| 314 | + kv = hf.KV_GB_PER_8K | |
| 315 | + avail = max(0.0, memory_gb - hf.RESERVED_GB - kv) | |
| 316 | + return int(avail * 1e9 / (hf.BYTES_PER_PARAM.get(quant, 0.5) * hf.OVERHEAD)) | |
| 317 | + | |
| 318 | + | |
| 319 | +# ------------------------------------------------------------------------------------------------------------------ SQL | |
| 320 | + | |
| 321 | + | |
| 322 | +def _num(path: str) -> str: | |
| 323 | + return f"(case when {path} ~ '^-?[0-9]+(\\.[0-9]+)?$' then ({path})::double precision end)" | |
| 324 | + | |
| 325 | + | |
| 326 | +def where_for(q: Query) -> tuple[list[str], dict[str, Any]]: | |
| 327 | + """Filters shared by `search_entities` and the count query. Every numeric cast is regex-guarded.""" | |
| 94 | 328 | where = ["e.merged_into is null"] |
| 95 | − params: dict[str, Any] = {"limit": limit, "offset": offset} | |
| 329 | + params: dict[str, Any] = {} | |
| 96 | 330 | if q.entity_type: |
| 97 | − where.append("e.entity_type = :etype") | |
| 331 | + where.append("e.entity_type = :etype" if q.entity_type != "model" else "e.entity_type = 'model'") | |
| 98 | 332 | params["etype"] = q.entity_type |
| 99 | 333 | if q.openness == "open": |
| 100 | 334 | where.append("(e.attributes->>'openness' in ('open-weights','open-source','open') or e.attributes->>'weights_availability' = 'open')") |
| 101 | 335 | elif q.openness == "proprietary": |
| 102 | 336 | where.append("e.attributes->>'openness' in ('proprietary','closed')") |
| 337 | + date_col = "coalesce(e.attributes->>'release_date', e.attributes->>'published_at')" | |
| 103 | 338 | if q.year_from: |
| 104 | − where.append("left(e.attributes->>'release_date', 4) >= :yf") | |
| 339 | + where.append(f"left({date_col}, 4) >= :yf") | |
| 105 | 340 | params["yf"] = str(q.year_from) |
| 106 | 341 | if q.year_to: |
| 107 | − where.append("left(e.attributes->>'release_date', 4) <= :yt") | |
| 342 | + where.append(f"left({date_col}, 4) <= :yt") | |
| 108 | 343 | params["yt"] = str(q.year_to) |
| 344 | + if q.days_back: | |
| 345 | + where.append(f"({date_col} >= to_char((now() at time zone 'UTC') - make_interval(days => :days), 'YYYY-MM-DD') or e.first_seen_at >= now() - make_interval(days => :days))") | |
| 346 | + params["days"] = int(q.days_back) | |
| 347 | + pmax = q.params_max | |
| 348 | + if q.memory_gb is not None: | |
| 349 | + bound = params_bound_from_memory(q.memory_gb) | |
| 350 | + pmax = min(pmax, bound) if pmax else bound | |
| 351 | + where.append("e.attributes ? 'parameter_count'") | |
| 109 | 352 | if q.params_min: |
| 110 | − where.append("(e.attributes->>'parameter_count')::double precision >= :pmin") | |
| 353 | + where.append(f"{_num('e.attributes->>' + chr(39) + 'parameter_count' + chr(39))} >= :pmin") | |
| 111 | 354 | params["pmin"] = float(q.params_min) |
| 112 | − if q.params_max: | |
| 113 | − where.append("(e.attributes->>'parameter_count')::double precision <= :pmax") | |
| 114 | − params["pmax"] = float(q.params_max) | |
| 355 | + if pmax: | |
| 356 | + where.append(f"{_num('e.attributes->>' + chr(39) + 'parameter_count' + chr(39))} <= :pmax") | |
| 357 | + params["pmax"] = float(pmax) | |
| 115 | 358 | if q.context_min: |
| 116 | − where.append("(e.attributes->>'context_length')::double precision >= :cmin") | |
| 359 | + where.append(f"{_num('e.attributes->>' + chr(39) + 'context_length' + chr(39))} >= :cmin") | |
| 117 | 360 | params["cmin"] = float(q.context_min) |
| 118 | 361 | for i, mod in enumerate(q.modalities): |
| 119 | − where.append(f"e.attributes->'modalities' ? :mod{i}") | |
| 362 | + where.append(f"(e.attributes->'modalities' ? :mod{i} or e.attributes->'modalities_input' ? :mod{i} or e.attributes->'modalities_output' ? :mod{i}" | |
| 363 | + + (" or e.attributes->>'vision' = 'true'" if mod == "image" else "") + ")") | |
| 120 | 364 | params[f"mod{i}"] = mod |
| 365 | + if q.reasoning: | |
| 366 | + where.append("e.attributes->>'reasoning' = 'true'") | |
| 367 | + if q.max_output_price is not None: | |
| 368 | + where.append("exists (select 1 from prices p where p.model_id = e.id and p.valid_to is null and p.output_per_mtok > 0 and p.output_per_mtok <= :maxout)") | |
| 369 | + params["maxout"] = float(q.max_output_price) | |
| 370 | + if q.max_input_price is not None: | |
| 371 | + where.append("exists (select 1 from prices p where p.model_id = e.id and p.valid_to is null and p.input_per_mtok > 0 and p.input_per_mtok <= :maxin)") | |
| 372 | + params["maxin"] = float(q.max_input_price) | |
| 373 | + if q.license_key: | |
| 374 | + where.append("(e.attributes->>'license_key' = :lic or lower(e.attributes->>'license') = any(cast(:lic_raw as text[])))") | |
| 375 | + params["lic"] = q.license_key | |
| 376 | + params["lic_raw"] = _raw_license_labels(q.license_key) | |
| 377 | + if q.commercial_use: | |
| 378 | + keys = _commercial_keys() | |
| 379 | + where.append("(e.attributes->>'license_key' = any(cast(:ckeys as text[])) or lower(e.attributes->>'license') = any(cast(:craw as text[])))") | |
| 380 | + params["ckeys"] = keys | |
| 381 | + params["craw"] = [lbl for k in keys for lbl in _raw_license_labels(k)] | |
| 121 | 382 | if q.organization: |
| 122 | − where.append("exists (select 1 from entities o where o.id = e.organization_id and o.canonical_name ilike :org)") | |
| 123 | − params["org"] = f"%{q.organization}%" | |
| 124 | − text = (q.filters.get("residual") or "").strip() or ("" if any([q.entity_type, q.openness, q.year_from, q.params_min, q.context_min, q.modalities]) else q.text) | |
| 383 | + where.append("exists (select 1 from entities o where o.id = e.organization_id and (o.canonical_name ilike :org or o.slug = :orgslug " | |
| 384 | + "or exists (select 1 from entity_aliases a where a.entity_id = o.id and a.alias ilike :org)))") | |
| 385 | + params["org"] = q.organization | |
| 386 | + params["orgslug"] = q.organization.lower().replace(" ", "-") | |
| 387 | + return where, params | |
| 388 | + | |
| 389 | + | |
| 390 | +def _raw_license_labels(key: str) -> list[str]: | |
| 391 | + from aiatlas.ontology.licenses import LICENSES | |
| 392 | + | |
| 393 | + info = LICENSES.get(key) | |
| 394 | + if not info: | |
| 395 | + return [key.lower()] | |
| 396 | + return sorted({key.lower(), *(a.lower() for a in info.aliases), *( [info.spdx.lower()] if info.spdx else [])}) | |
| 397 | + | |
| 398 | + | |
| 399 | +def _commercial_keys() -> list[str]: | |
| 400 | + from aiatlas.ontology.licenses import LICENSES | |
| 401 | + | |
| 402 | + return sorted(k for k, v in LICENSES.items() if v.commercial_use is True and v.weights_downloadable) | |
| 403 | + | |
| 404 | + | |
| 405 | +SORT_SQL = { | |
| 406 | + "cheapest": "(select min(p.output_per_mtok) from prices p where p.model_id = e.id and p.valid_to is null and p.output_per_mtok > 0) asc nulls last", | |
| 407 | + "newest": "coalesce(e.attributes->>'release_date', e.attributes->>'published_at', '') desc", | |
| 408 | + "largest": _num("e.attributes->>'parameter_count'") + " desc nulls last", | |
| 409 | + "smallest": _num("e.attributes->>'parameter_count'") + " asc nulls last", | |
| 410 | +} | |
| 411 | + | |
| 412 | + | |
| 413 | +async def search_entities(conn: AsyncConnection, q: Query, *, limit: int = 30, offset: int = 0, embedding: list[float] | None = None) -> list[dict[str, Any]]: | |
| 414 | + where, params = where_for(q) | |
| 415 | + params.update({"limit": limit, "offset": offset}) | |
| 416 | + text = (q.residual or q.filters.get("residual") or "").strip() or ("" if q.has_structure else q.text) | |
| 417 | + if q.benchmark and q.entity_type == "benchmark": | |
| 418 | + text = q.benchmark | |
| 419 | + if q.provider and q.entity_type == "provider": | |
| 420 | + text = q.provider | |
| 125 | 421 | rank = "coalesce((e.quality->>'score')::float, 0) / 100.0" |
| 126 | 422 | if text: |
| 127 | 423 | params["q"] = text |
@@ -138,13 +434,25 @@ async def search_entities(conn: AsyncConnection, q: Query, *, limit: int = 30, o | ||
| 138 | 434 | join = "left join entity_embeddings x on x.entity_id = e.id" |
| 139 | 435 | else: |
| 140 | 436 | join = "" |
| 437 | + order = SORT_SQL.get(q.sort or "", None) | |
| 438 | + order_sql = f"{order}, rank desc" if order and q.entity_type in (None, "model") else "rank desc, e.updated_at desc" | |
| 141 | 439 | sql = f"""select e.id, e.entity_type, e.canonical_name, e.slug, left(e.description, 240) as description, e.status, e.attributes, e.quality, |
| 142 | 440 | o.canonical_name as organization_name, o.slug as organization_slug, {rank} as rank |
| 143 | 441 | from entities e left join entities o on o.id = e.organization_id {join} |
| 144 | − where {' and '.join(where)} order by rank desc, e.updated_at desc limit :limit offset :offset""" | |
| 442 | + where {' and '.join(where)} order by {order_sql} limit :limit offset :offset""" | |
| 145 | 443 | return await fetch_all(conn, sql, **params) |
| 146 | 444 | |
| 147 | 445 | |
| 446 | +async def verify_organization(conn: AsyncConnection, name: str | None) -> dict[str, Any] | None: | |
| 447 | + """The compiler only *proposes* an organization; it counts only when it matches an organization-like entity or one of its aliases.""" | |
| 448 | + if not name: | |
| 449 | + return None | |
| 450 | + rows = await fetch_all(conn, """select e.id, e.slug, e.canonical_name from entities e where e.entity_type in ('company','organization','lab','university') and e.merged_into is null | |
| 451 | + and (e.canonical_name ilike :n or e.slug = :s or exists (select 1 from entity_aliases a where a.entity_id = e.id and a.alias ilike :n)) | |
| 452 | + order by length(e.canonical_name) limit 1""", n=name, s=name.lower().replace(" ", "-")) | |
| 453 | + return rows[0] if rows else None | |
| 454 | + | |
| 455 | + | |
| 148 | 456 | async def suggest(conn: AsyncConnection, prefix: str, *, limit: int = 8) -> list[dict[str, Any]]: |
| 149 | 457 | return await fetch_all(conn, """select e.id, e.entity_type, e.canonical_name, e.slug, o.canonical_name as organization_name |
| 150 | 458 | from entities e left join entities o on o.id = e.organization_id |
@@ -153,4 +461,4 @@ async def suggest(conn: AsyncConnection, prefix: str, *, limit: int = 8) -> list | ||
| 153 | 461 | coalesce((e.quality->>'score')::float, 0) desc, length(e.canonical_name) limit :n""", p=f"{prefix}%", n=limit) |
| 154 | 462 | |
| 155 | 463 | |
| 156 | −__all__ = ["Query", "compile_query", "search_entities", "suggest"] | |
| 464 | +__all__ = ["Query", "compile_query", "params_bound_from_memory", "search_entities", "suggest", "verify_organization", "where_for"] | |
modified
src/aiatlas/services/stats.py
+36 −4
@@ -1,4 +1,8 @@ | ||
| 1 | −"""Live counters and aggregate statistics — always computed from the database, never hardcoded.""" | |
| 1 | +"""Live counters and aggregate statistics — always computed from the database, never hardcoded. | |
| 2 | + | |
| 3 | +API 1.1 semantics: `entities.model` counts CANONICAL model releases (entity_type 'model', not merged) — artifacts (checkpoints, quantisations, | |
| 4 | +conversions) are `entities.artifact`, folded evaluation variants are excluded through `merged_into`. `organizations_total` is the number | |
| 5 | +shown by /companies (company + organization + lab + university). `definitions` says how every counter is counted.""" | |
| 2 | 6 | from __future__ import annotations |
| 3 | 7 | |
| 4 | 8 | from typing import Any |
@@ -8,6 +12,25 @@ from sqlalchemy.ext.asyncio import AsyncConnection | ||
| 8 | 12 | from aiatlas.db import execute, fetch_all, fetch_one, jsonb, transaction |
| 9 | 13 | from aiatlas.sdk.archive import archive_size |
| 10 | 14 | |
| 15 | +ORG_TYPES = ("company", "organization", "lab", "university") | |
| 16 | + | |
| 17 | +DEFINITIONS: dict[str, str] = { | |
| 18 | + "models": "Canonical model releases (artifacts, quantisations, conversions and folded evaluation variants excluded; merged duplicates excluded).", | |
| 19 | + "artifacts": "Checkpoints, quantisations, conversions and packagings of a canonical model (entity_type 'artifact').", | |
| 20 | + "model_families": "Model families (Llama 4, Qwen3.6, Claude…) grouping canonical releases.", | |
| 21 | + "organizations_total": "Companies + organizations + labs + universities, merged duplicates excluded — the same universe as /companies.", | |
| 22 | + "entities_total": "All live entities of every type (merged duplicates excluded).", | |
| 23 | + "change_events": "All change events ever recorded, including the initial back-filled corpus.", | |
| 24 | + "change_events_24h": "Events OBSERVED in the last 24 hours (legacy counter — includes back-filled history when a connector first runs).", | |
| 25 | + "change_events_live_24h": "Events that OCCURRED in the last 24 hours, excluding back-fill and source-document changes — what actually happened today.", | |
| 26 | + "change_events_7d": "Events observed in the last 7 days (legacy counter).", | |
| 27 | + "benchmark_results": "Current benchmark result rows (one per model × benchmark × metric × configuration run).", | |
| 28 | + "prices_current": "Live provider price offers (model × provider × provider model id).", | |
| 29 | + "claims_current": "Current temporal claims (one per entity × property).", | |
| 30 | + "relations": "Live relations in the knowledge graph.", | |
| 31 | + "sources": "Enabled sources (websites, registries, leaderboards) crawled by AI Atlas connectors.", | |
| 32 | +} | |
| 33 | + | |
| 11 | 34 | |
| 12 | 35 | async def live_counts(conn: AsyncConnection) -> dict[str, Any]: |
| 13 | 36 | by_type = await fetch_all(conn, "select entity_type, count(*) as n from entities where merged_into is null group by 1") |
@@ -22,8 +45,12 @@ async def live_counts(conn: AsyncConnection) -> dict[str, Any]: | ||
| 22 | 45 | (select count(*) from relations where valid_to is null) as relations, |
| 23 | 46 | (select count(*) from change_events) as change_events, |
| 24 | 47 | (select count(*) from change_events where observed_at > now() - interval '24 hours') as change_events_24h, |
| 48 | + (select count(*) from change_events where is_backfill = false and event_type <> 'DOCUMENT_CHANGED' | |
| 49 | + and occurred_at > now() - interval '24 hours') as change_events_live_24h, | |
| 50 | + (select count(*) from change_events where is_backfill = false and event_type <> 'DOCUMENT_CHANGED' | |
| 51 | + and occurred_at > now() - interval '7 days') as change_events_live_7d, | |
| 25 | 52 | (select count(*) from change_events where observed_at > now() - interval '7 days') as change_events_7d, |
| 26 | − (select count(*) from benchmark_results where valid_to is null) as benchmark_results, | |
| 53 | + (select count(*) from benchmark_results where valid_to is null and is_current) as benchmark_results, | |
| 27 | 54 | (select count(*) from prices where valid_to is null) as prices_current, |
| 28 | 55 | (select count(*) from prices) as prices_total, |
| 29 | 56 | (select count(*) from jobs where status = 'queued') as jobs_queued, |
@@ -34,7 +61,12 @@ async def live_counts(conn: AsyncConnection) -> dict[str, Any]: | ||
| 34 | 61 | (select max(observed_at) from snapshots) as last_snapshot_at, |
| 35 | 62 | (select max(observed_at) from change_events) as last_event_at, |
| 36 | 63 | (select min(first_seen_at) from entities) as first_entity_at""") |
| 37 | − return {"entities": counts, "entities_total": sum(counts.values()), **{k: (int(v) if isinstance(v, int) else v) for k, v in (infra or {}).items()}} | |
| 64 | + out = {"entities": counts, "entities_total": sum(counts.values()), **{k: (int(v) if isinstance(v, int) else v) for k, v in (infra or {}).items()}} | |
| 65 | + out["organizations_total"] = sum(counts.get(t, 0) for t in ORG_TYPES) | |
| 66 | + out["artifacts"] = counts.get("artifact", 0) | |
| 67 | + out["model_families"] = counts.get("model_family", 0) | |
| 68 | + out["definitions"] = DEFINITIONS | |
| 69 | + return out | |
| 38 | 70 | |
| 39 | 71 | |
| 40 | 72 | async def compute_stats() -> dict[str, Any]: |
@@ -50,4 +82,4 @@ async def history(conn: AsyncConnection, days: int = 90) -> list[dict[str, Any]] | ||
| 50 | 82 | from stats_snapshots where computed_at > now() - make_interval(days => :d) order by 1 desc, computed_at desc""", d=days) |
| 51 | 83 | |
| 52 | 84 | |
| 53 | −__all__ = ["compute_stats", "history", "live_counts"] | |
| 85 | +__all__ = ["DEFINITIONS", "ORG_TYPES", "compute_stats", "history", "live_counts"] | |
added
tests/test_api_services.py
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +"""Unit tests for the pure API 1.1 services: Pareto frontier, cost arithmetic, search compiler v2, hardware fit breakdown. No database.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from datetime import UTC, datetime | |
| 5 | + | |
| 6 | +import pytest | |
| 7 | + | |
| 8 | +from aiatlas.services import hardware_fit as hf | |
| 9 | +from aiatlas.services.cost import compute_cost, context_fill_cost | |
| 10 | +from aiatlas.services.pareto import is_dominated, pareto_frontier | |
| 11 | +from aiatlas.services.search import compile_query, params_bound_from_memory | |
| 12 | + | |
| 13 | +# ------------------------------------------------------------------------------------------------------------------ pareto | |
| 14 | + | |
| 15 | + | |
| 16 | +def test_pareto_frontier_is_efficient_and_keeps_ties() -> None: | |
| 17 | + pts = [ | |
| 18 | + {"id": "a", "x": 1.0, "y": 50}, # cheap, low quality → efficient | |
| 19 | + {"id": "b", "x": 2.0, "y": 70}, # efficient | |
| 20 | + {"id": "c", "x": 3.0, "y": 65}, # dominated by b (more expensive, lower) | |
| 21 | + {"id": "d", "x": 5.0, "y": 90}, # efficient | |
| 22 | + {"id": "e", "x": 5.0, "y": 90}, # exact tie with d → kept | |
| 23 | + {"id": "f", "x": 6.0, "y": 90}, # dominated by d (same y, more expensive) | |
| 24 | + {"id": "g", "x": None, "y": 99}, # ignored | |
| 25 | + ] | |
| 26 | + front = pareto_frontier(pts) | |
| 27 | + assert front == ["a", "b", "d", "e"] | |
| 28 | + valid = [p for p in pts if p["x"] is not None] | |
| 29 | + for p in valid: | |
| 30 | + assert is_dominated(p, valid) == (p["id"] not in front), p["id"] | |
| 31 | + | |
| 32 | + | |
| 33 | +def test_pareto_minimise_y() -> None: | |
| 34 | + pts = [{"id": "a", "x": 1, "y": 10}, {"id": "b", "x": 2, "y": 5}, {"id": "c", "x": 3, "y": 7}] | |
| 35 | + assert pareto_frontier(pts, maximize_y=False) == ["a", "b"] | |
| 36 | + | |
| 37 | + | |
| 38 | +# ------------------------------------------------------------------------------------------------------------------ cost | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_cost_arithmetic_standard() -> None: | |
| 42 | + c = compute_cost({"input": 3.0, "output": 15.0}, input_tokens=1000, output_tokens=500, requests_per_day=1000) | |
| 43 | + assert c["per_request"] == pytest.approx(1000 * 3 / 1e6 + 500 * 15 / 1e6) | |
| 44 | + assert c["daily"] == pytest.approx(c["per_request"] * 1000) | |
| 45 | + assert c["monthly"] == pytest.approx(c["daily"] * 30) and c["annual"] == pytest.approx(c["daily"] * 365) | |
| 46 | + assert c["notes"] == [] | |
| 47 | + | |
| 48 | + | |
| 49 | +def test_cost_cached_and_batch_with_notes() -> None: | |
| 50 | + c = compute_cost({"input": 3.0, "output": 15.0, "cached_input": 0.3}, input_tokens=1_000_000, output_tokens=0, cached_share=0.5) | |
| 51 | + assert c["effective_input_per_mtok"] == pytest.approx(1.65) and c["per_request"] == pytest.approx(1.65) | |
| 52 | + c2 = compute_cost({"input": 3.0, "output": 15.0}, input_tokens=1_000_000, output_tokens=0, cached_share=0.5) | |
| 53 | + assert c2["per_request"] == pytest.approx(3.0) and any("cached input price not published" in n for n in c2["notes"]) | |
| 54 | + c3 = compute_cost({"input": 3.0, "output": 15.0, "batch_input": 1.5, "batch_output": 7.5}, input_tokens=1_000_000, output_tokens=1_000_000, batch=True) | |
| 55 | + assert c3["per_request"] == pytest.approx(9.0) | |
| 56 | + c4 = compute_cost({"input": 3.0, "output": 15.0}, input_tokens=1_000_000, output_tokens=1_000_000, batch=True) | |
| 57 | + assert c4["per_request"] == pytest.approx(18.0) and any("batch prices not published" in n for n in c4["notes"]) | |
| 58 | + missing = compute_cost({"input": None, "output": 15.0}, input_tokens=10, output_tokens=10) | |
| 59 | + assert missing["per_request"] is None and "input price unavailable" in missing["notes"] | |
| 60 | + assert context_fill_cost(2.0, 1_000_000) == 2.0 and context_fill_cost(None, 10) is None | |
| 61 | + | |
| 62 | + | |
| 63 | +# ------------------------------------------------------------------------------------------------------------------ search compiler v2 | |
| 64 | + | |
| 65 | +YEAR = datetime.now(UTC).year | |
| 66 | + | |
| 67 | + | |
| 68 | +def _d(q: str) -> dict: | |
| 69 | + d = compile_query(q).as_dict() | |
| 70 | + d.pop("compiled"), d.pop("text"), d.pop("filters") | |
| 71 | + return d | |
| 72 | + | |
| 73 | + | |
| 74 | +def test_compile_open_models_over_100b_this_year() -> None: | |
| 75 | + d = _d("open models over 100B released this year") | |
| 76 | + assert d["entity_type"] == "model" and d["openness"] == "open" and d["params_min"] == 100_000_000_000 | |
| 77 | + assert d["year_from"] == YEAR and d["year_to"] == YEAR and d["residual"] == "" | |
| 78 | + | |
| 79 | + | |
| 80 | +def test_compile_cheapest_1m_context() -> None: | |
| 81 | + d = _d("cheapest models with 1M context") | |
| 82 | + assert d["entity_type"] == "model" and d["context_min"] == 1_000_000 and d["sort"] == "cheapest" and d["residual"] == "" | |
| 83 | + | |
| 84 | + | |
| 85 | +def test_compile_reasoning_under_price() -> None: | |
| 86 | + d = _d("reasoning models under $1/M tokens") | |
| 87 | + assert d["reasoning"] is True and d["max_output_price"] == 1.0 and d["entity_type"] == "model" | |
| 88 | + | |
| 89 | + | |
| 90 | +def test_compile_fits_in_memory() -> None: | |
| 91 | + d = _d("models that fit in 64GB") | |
| 92 | + assert d["memory_gb"] == 64.0 and d["entity_type"] == "model" | |
| 93 | + assert 0 < params_bound_from_memory(64.0) < 200e9 | |
| 94 | + | |
| 95 | + | |
| 96 | +def test_compile_org_since_year() -> None: | |
| 97 | + d = _d("Anthropic models released since 2025") | |
| 98 | + assert d["organization"] == "Anthropic" and d["year_from"] == 2025 and "year_to" not in d and d["entity_type"] == "model" | |
| 99 | + | |
| 100 | + | |
| 101 | +def test_compile_open_vision_apache() -> None: | |
| 102 | + d = _d("open vision models with Apache license") | |
| 103 | + assert d["openness"] == "open" and d["modalities"] == ["image"] and d["license_key"] == "Apache-2.0" | |
| 104 | + | |
| 105 | + | |
| 106 | +def test_compile_papers_free_text() -> None: | |
| 107 | + d = _d("papers introducing MoE models") | |
| 108 | + assert d["entity_type"] == "paper" and "organization" not in d and "MoE" in d["residual"] | |
| 109 | + | |
| 110 | + | |
| 111 | +def test_compile_misc_rules() -> None: | |
| 112 | + assert _d("models between 7B and 70B")["params_min"] == 7_000_000_000 and _d("models between 7B and 70B")["params_max"] == 70_000_000_000 | |
| 113 | + assert _d("benchmark gpqa")["benchmark"] == "gpqa" and _d("provider groq")["provider"] == "groq" | |
| 114 | + d = _d("open source models with commercial use released in the last 30 days") | |
| 115 | + assert d["commercial_use"] is True and d["days_back"] == 30 and d["residual"] == "" | |
| 116 | + assert _d("largest proprietary models")["sort"] == "largest" and _d("largest proprietary models")["openness"] == "proprietary" | |
| 117 | + plain = compile_query("claude") | |
| 118 | + assert plain.residual == "claude" and plain.unrecognised == [] and not plain.has_structure | |
| 119 | + comp = compile_query("open models over 100B").compiled | |
| 120 | + assert {c["filter"] for c in comp} >= {"openness", "params_min", "entity_type"} and all("label" in c and "source_span" in c for c in comp) | |
| 121 | + | |
| 122 | + | |
| 123 | +# ------------------------------------------------------------------------------------------------------------------ hardware fit | |
| 124 | + | |
| 125 | + | |
| 126 | +def test_fit_detailed_observed_vs_estimated_and_kv() -> None: | |
| 127 | + est = hf.fit_detailed({"parameter_count": 70e9}, 64, quant="4bit", context=8192) | |
| 128 | + assert est and est["estimated"] is True and est["breakdown"]["weights_source"] == "estimated" and est["breakdown"]["kv_cache_method"] == "heuristic" | |
| 129 | + obs = hf.fit_detailed({"parameter_count": 70e9}, 64, quant="4bit", context=8192, observed_size_gb=40.0) | |
| 130 | + assert obs and obs["breakdown"]["weights_gb"] == 40.0 and obs["breakdown"]["weights_source"] == "observed" | |
| 131 | + arch = hf.fit_detailed({"parameter_count": 8e9, "num_hidden_layers": 32, "num_key_value_heads": 8, "head_dim": 128}, 24, quant="4bit", context=8192) | |
| 132 | + assert arch and arch["breakdown"]["kv_cache_method"] == "architecture" and arch["breakdown"]["kv_cache_gb"] == pytest.approx(2 * 32 * 8 * 128 * 2 * 8192 / 1e9, rel=1e-3) | |
| 133 | + multi = hf.fit_detailed({"parameter_count": 400e9}, 80, quant="8bit", gpu_count=8) | |
| 134 | + assert multi and multi["device"]["total_memory_gb"] == 640 and "multi_gpu_note" in multi | |
| 135 | + assert hf.fit_detailed({}, 64) is None # nothing estimated from thin air | |
| 136 | + assert hf.fit(70e9, 64, "4bit", 8192)["estimated"] is True # v1 helper unchanged | |
added
tests/test_api_v11.py
+284 −0
@@ -0,0 +1,284 @@ | ||
| 1 | +"""API 1.1 contract tests against the live local database (docs/API.md). Read-only. Counts are never asserted as fixed numbers.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from collections.abc import AsyncIterator | |
| 5 | + | |
| 6 | +import pytest | |
| 7 | +from httpx import ASGITransport, AsyncClient | |
| 8 | + | |
| 9 | +from aiatlas import db | |
| 10 | +from aiatlas.api.main import app | |
| 11 | +from aiatlas.config import settings | |
| 12 | +from aiatlas.services import cache | |
| 13 | + | |
| 14 | +ADMIN = {"x-aia-admin-token": settings.admin_token or "dev-admin-token"} | |
| 15 | +MODEL, MODEL_B, BENCH = "claude-opus-5", "claude-sonnet-5", "gpqa" | |
| 16 | + | |
| 17 | + | |
| 18 | +@pytest.fixture | |
| 19 | +async def client() -> AsyncIterator[AsyncClient]: | |
| 20 | + await cache.cache_invalidate() | |
| 21 | + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test", timeout=120) as c: | |
| 22 | + yield c | |
| 23 | + await cache.close() | |
| 24 | + await db.dispose() | |
| 25 | + | |
| 26 | + | |
| 27 | +async def test_models_canonical_universe_and_artifacts(client: AsyncClient) -> None: | |
| 28 | + r = await client.get("/api/v1/models", params={"limit": 200}) | |
| 29 | + assert r.status_code == 200 | |
| 30 | + body = r.json() | |
| 31 | + assert body["universe"] == "canonical models" and body["items"] | |
| 32 | + assert all(it["entity_type"] == "model" for it in body["items"]) | |
| 33 | + assert all("identity_confidence" in it for it in body["items"]) | |
| 34 | + r2 = await client.get("/api/v1/models", params={"limit": 200, "include": "artifacts"}) | |
| 35 | + assert r2.status_code == 200 and r2.json()["universe"] == "models+artifacts" and r2.json()["total"] >= body["total"] | |
| 36 | + for it in r2.json()["items"]: | |
| 37 | + if it["entity_type"] == "artifact": | |
| 38 | + assert "canonical" in it and "artifact_kind" in it | |
| 39 | + facets = (await client.get("/api/v1/models", params={"facets": 1, "limit": 1})).json()["facets"] | |
| 40 | + assert {"families", "licenses", "trust", "definitions"} <= set(facets) | |
| 41 | + assert all({"value", "label", "category", "count"} <= set(x) for x in facets["licenses"]) | |
| 42 | + lic = await client.get("/api/v1/models", params={"license": "Apache 2.0", "limit": 5}) # raw label → canonical key | |
| 43 | + assert lic.status_code == 200 | |
| 44 | + assert (await client.get("/api/v1/models", params={"family": "Claude", "limit": 2})).status_code == 200 | |
| 45 | + | |
| 46 | + | |
| 47 | +async def test_model_detail_v11_blocks(client: AsyncClient) -> None: | |
| 48 | + d = (await client.get(f"/api/v1/models/{MODEL}")).json() | |
| 49 | + for block in ("family", "artifacts", "deployments", "identity", "openness", "version_history", "benchmarks"): | |
| 50 | + assert block in d, block | |
| 51 | + assert {"items", "total"} <= set(d["artifacts"]) and {"canonical_model", "official_checkpoints", "third_party_artifacts", "provider_deployments", "api_aliases"} <= set(d["identity"]) | |
| 52 | + assert d["openness"]["category"] in ("open-source", "open-weights", "restricted-weights", "proprietary", "unknown") and "dimensions" in d["openness"] | |
| 53 | + assert all({"property", "transitions"} <= set(v) for v in d["version_history"]) | |
| 54 | + if d["deployments"]: | |
| 55 | + dep = d["deployments"][0] | |
| 56 | + assert {"model", "provider", "prices", "features", "status", "valid_from"} <= set(dep) and {"input", "output", "native_units"} <= set(dep["prices"]) | |
| 57 | + assert {"items", "total_rows", "note"} <= set(d["benchmarks"]) | |
| 58 | + for b in d["benchmarks"]["items"]: | |
| 59 | + for m in b["metrics"]: | |
| 60 | + for g in m["groups"]: | |
| 61 | + assert {"config_key", "comparability_group", "n_rows", "best"} <= set(g) and "trust_level" in g["best"] | |
| 62 | + | |
| 63 | + | |
| 64 | +async def test_leaderboard_one_row_per_model(client: AsyncClient) -> None: | |
| 65 | + r = await client.get(f"/api/v1/benchmarks/{BENCH}/leaderboard", params={"limit": 1000}) | |
| 66 | + assert r.status_code == 200 | |
| 67 | + body = r.json() | |
| 68 | + assert body["group"] and {"metric", "config_key", "label", "n", "model_count"} <= set(body["group"]) and body["groups"] | |
| 69 | + ids = [it["model"]["id"] for it in body["items"]] | |
| 70 | + assert ids and len(ids) == len(set(ids)), "one row per model" | |
| 71 | + ranks = [it["rank"] for it in body["items"]] | |
| 72 | + assert ranks[0] == 1 and ranks == sorted(ranks) | |
| 73 | + first = body["items"][0] | |
| 74 | + assert {"rank", "model", "score", "trust_level", "config", "config_key", "comparability", "comparability_reasons", "delta_rank", "n_rows"} <= set(first) | |
| 75 | + assert first["comparability"] in ("comparable", "partially-comparable", "not-comparable") | |
| 76 | + only = (await client.get(f"/api/v1/benchmarks/{BENCH}/leaderboard", params={"comparable_only": 1, "limit": 50})).json() | |
| 77 | + assert all(it["comparability"] == "comparable" for it in only["items"]) | |
| 78 | + alias = await client.get("/api/v1/benchmarks/GPQA Diamond/leaderboard", params={"limit": 3}) # alias resolution | |
| 79 | + assert alias.status_code == 200 and alias.json()["benchmark"]["slug"] == BENCH | |
| 80 | + listing = (await client.get("/api/v1/benchmarks")).json() | |
| 81 | + b = next(x for x in listing["items"] if x["slug"] == BENCH) | |
| 82 | + assert {"family", "variant", "metric", "direction", "result_count", "model_count", "leader", "groups", "trust_mix", "category"} <= set(b) | |
| 83 | + res = (await client.get(f"/api/v1/benchmarks/{BENCH}/results", params={"limit": 3, "metric": "accuracy"})).json() | |
| 84 | + assert res["items"] and all(it["metric"].lower() == "accuracy" for it in res["items"]) | |
| 85 | + fr = (await client.get(f"/api/v1/benchmarks/{BENCH}/frontier")).json() | |
| 86 | + assert "series" in fr and all({"group", "points", "current_leader"} <= set(s) for s in fr["series"]) | |
| 87 | + | |
| 88 | + | |
| 89 | +async def test_matrix_shape(client: AsyncClient) -> None: | |
| 90 | + body = (await client.get("/api/v1/benchmarks/matrix", params={"limit": 5})).json() | |
| 91 | + assert {"columns", "rows", "total_rows", "methodology", "min_cells"} <= set(body) | |
| 92 | + assert 1 <= len(body["columns"]) <= 12 and all({"id", "slug", "metric", "config_key", "group_label"} <= set(c) for c in body["columns"]) | |
| 93 | + col_ids = [c["id"] for c in body["columns"]] | |
| 94 | + for row in body["rows"]: | |
| 95 | + assert set(row["cells"]) == set(col_ids) and row["n_cells"] >= 3 and {"model", "mean_rank"} <= set(row) | |
| 96 | + for cell in row["cells"].values(): | |
| 97 | + if cell: | |
| 98 | + assert {"score", "rank", "trust_level", "config_key", "comparability"} <= set(cell) | |
| 99 | + sub = (await client.get("/api/v1/benchmarks/matrix", params={"benchmarks": f"{BENCH},swe-bench-verified", "org": "anthropic"})).json() | |
| 100 | + assert [c["slug"] for c in sub["columns"]] == [BENCH, "swe-bench-verified"] | |
| 101 | + | |
| 102 | + | |
| 103 | +async def test_changes_default_excludes_backfill(client: AsyncClient) -> None: | |
| 104 | + body = (await client.get("/api/v1/changes", params={"limit": 20})).json() | |
| 105 | + assert body["date_field"] == "occurred" and body["include_backfill"] is False | |
| 106 | + assert all(e["is_backfill"] is False and "occurred_at" in e for e in body["items"]) | |
| 107 | + if body["items"] and body["next_before"]: | |
| 108 | + assert body["next_before"] == body["items"][-1]["occurred_at"] | |
| 109 | + obs = (await client.get("/api/v1/changes", params={"limit": 3, "include_backfill": 1, "date_field": "observed"})).json() | |
| 110 | + assert obs["date_field"] == "observed" and obs["total"] >= body["total"] or obs["total"] == 10000 | |
| 111 | + daily = (await client.get("/api/v1/changes/daily")).json() | |
| 112 | + assert {"date", "counts", "sections", "new_models", "today", "backfill_excluded"} <= set(daily) | |
| 113 | + for s in daily["today"]: | |
| 114 | + assert {"key", "label", "items", "total"} <= set(s) and all({"sources", "documents", "grouped_events"} <= set(i) for i in s["items"]) | |
| 115 | + tl = (await client.get(f"/api/v1/entities/{MODEL}/timeline", params={"limit": 3})).json() | |
| 116 | + assert tl["date_field"] == "occurred" and all(e["is_backfill"] is False for e in tl["items"]) | |
| 117 | + gt = (await client.get("/api/v1/timeline", params={"limit": 5})).json() | |
| 118 | + assert gt["include_backfill"] is False | |
| 119 | + | |
| 120 | + | |
| 121 | +async def test_stats_definitions(client: AsyncClient) -> None: | |
| 122 | + s = (await client.get("/api/v1/stats")).json() | |
| 123 | + assert {"organizations_total", "artifacts", "model_families", "definitions", "change_events_live_24h", "change_events_24h"} <= set(s) | |
| 124 | + assert s["organizations_total"] == sum(s["entities"].get(t, 0) for t in ("company", "organization", "lab", "university")) | |
| 125 | + companies = (await client.get("/api/v1/companies", params={"limit": 1})).json() | |
| 126 | + assert companies["total"] == s["organizations_total"] | |
| 127 | + assert "models" in s["definitions"] and "artifact" in s["entities"] and "model_family" in s["entities"] | |
| 128 | + | |
| 129 | + | |
| 130 | +async def test_frontier_shape(client: AsyncClient) -> None: | |
| 131 | + body = (await client.get("/api/v1/frontier")).json() | |
| 132 | + for key in ("latest_major_models", "benchmark_frontier", "price_frontier", "context_frontier", "open_weight_frontier", "efficiency_frontier", "agentic_frontier", | |
| 133 | + "multimodal_frontier", "recent_frontier_movements", "methodology"): | |
| 134 | + assert key in body, key | |
| 135 | + for b in body["benchmark_frontier"]: | |
| 136 | + assert {"benchmark", "group", "leader", "second", "gap"} <= set(b) and b["group"]["n"] >= 20 | |
| 137 | + assert {"cheapest_output", "cheapest_output_1m_context", "frontier_models", "composition"} <= set(body["price_frontier"]) | |
| 138 | + assert {"points", "frontier"} <= set(body["efficiency_frontier"]) | |
| 139 | + assert "dimensions" in body["open_weight_frontier"] and "score" not in str(body["open_weight_frontier"]["dimensions"]) | |
| 140 | + | |
| 141 | + | |
| 142 | +async def test_pareto_frontier_ids_are_efficient(client: AsyncClient) -> None: | |
| 143 | + body = (await client.get("/api/v1/pareto", params={"benchmark": BENCH})).json() | |
| 144 | + assert {"group", "points", "frontier", "methodology", "x", "y"} <= set(body) | |
| 145 | + pts = {p["id"]: p for p in body["points"]} | |
| 146 | + front = set(body["frontier"]) | |
| 147 | + assert front <= set(pts) | |
| 148 | + hib = body["group"]["higher_is_better"] | |
| 149 | + for fid in front: # no other point is at least as good on both axes and strictly better on one | |
| 150 | + p = pts[fid] | |
| 151 | + for o in pts.values(): | |
| 152 | + if o["id"] == fid: | |
| 153 | + continue | |
| 154 | + better_y = o["y"] > p["y"] if hib else o["y"] < p["y"] | |
| 155 | + ge_y = o["y"] >= p["y"] if hib else o["y"] <= p["y"] | |
| 156 | + assert not (o["x"] <= p["x"] and ge_y and (o["x"] < p["x"] or better_y)), (fid, o["id"]) | |
| 157 | + assert all(p["pareto"] == (p["id"] in front) for p in body["points"]) | |
| 158 | + assert (await client.get("/api/v1/pareto", params={"benchmark": BENCH, "x": "latency"})).status_code == 400 | |
| 159 | + | |
| 160 | + | |
| 161 | +async def test_cost_routes(client: AsyncClient) -> None: | |
| 162 | + body = (await client.get("/api/v1/cost", params={"model": MODEL, "input_tokens": 1000, "output_tokens": 500, "requests_per_day": 100, "cached_share": 0.5})).json() | |
| 163 | + assert {"items", "methodology", "inputs"} <= set(body) | |
| 164 | + for it in body["items"]: | |
| 165 | + c, p = it["cost"], it["deployment"]["prices"] | |
| 166 | + if c["per_request"] is not None and p["input"] is not None and p["output"] is not None: | |
| 167 | + assert c["daily"] == pytest.approx(c["per_request"] * 100) and c["monthly"] == pytest.approx(c["daily"] * 30) | |
| 168 | + ctx = (await client.get("/api/v1/cost/context", params={"tokens": 100000, "limit": 5})).json() | |
| 169 | + assert ctx["items"] and all(it["context_length"] >= 100000 and it["cost_usd"] == pytest.approx(it["deployment"]["prices"]["input"] * 0.1) for it in ctx["items"]) | |
| 170 | + deps = (await client.get("/api/v1/deployments", params={"model": MODEL})).json() | |
| 171 | + assert deps["items"] and all(d["status"] == "active" for d in deps["items"]) | |
| 172 | + | |
| 173 | + | |
| 174 | +async def test_find_a_model_returns_why(client: AsyncClient) -> None: | |
| 175 | + body = (await client.get("/api/v1/find-a-model", params={"use_case": "coding", "limit": 5})).json() | |
| 176 | + assert {"matches", "filters_applied", "note", "rules"} <= set(body) and body["matches"] | |
| 177 | + for m in body["matches"]: | |
| 178 | + assert m["why"] and {"model", "observed"} <= set(m) and "best_rank" in m["observed"] | |
| 179 | + assert "score" not in m # no composite winner score | |
| 180 | + local = (await client.get("/api/v1/find-a-model", params={"use_case": "local", "memory_gb": 64, "limit": 3})).json() | |
| 181 | + assert all("estimated_fit" in m and m["estimated_fit"]["estimated"] is True for m in local["matches"]) | |
| 182 | + assert (await client.get("/api/v1/find-a-model", params={"use_case": "nope"})).status_code == 400 | |
| 183 | + | |
| 184 | + | |
| 185 | +async def test_open_run_locally_families_graph(client: AsyncClient) -> None: | |
| 186 | + op = (await client.get("/api/v1/open", params={"limit": 3})).json() | |
| 187 | + assert {"items", "summary", "note"} <= set(op) and all({"licence", "dimensions", "best_results", "hardware_fit", "providers"} <= set(i) for i in op["items"]) | |
| 188 | + rl = (await client.get("/api/v1/run-locally", params={"memory_gb": 64, "limit": 3})).json() | |
| 189 | + assert rl["estimated"] is True and all(i["fit"]["fits"] and "breakdown" in i["fit"] for i in rl["items"]) | |
| 190 | + fams = (await client.get("/api/v1/families", params={"limit": 3})).json() | |
| 191 | + assert fams["items"] and all({"name", "model_count", "canonical", "licenses", "benchmark_best"} <= set(f) for f in fams["items"]) | |
| 192 | + fd = await client.get(f"/api/v1/families/{fams['items'][0]['slug']}") | |
| 193 | + assert fd.status_code == 200 and {"members", "timeline", "lineage", "artifacts_count"} <= set(fd.json()) | |
| 194 | + g = (await client.get("/api/v1/graph/explore", params={"node": MODEL, "mode": "company", "depth": 2, "limit": 20})).json() | |
| 195 | + assert {"nodes", "edges", "truncated", "counts"} <= set(g) and len(g["nodes"]) <= 20 | |
| 196 | + assert (await client.get("/api/v1/graph/explore", params={"node": MODEL, "mode": "nope"})).status_code == 400 | |
| 197 | + | |
| 198 | + | |
| 199 | +async def test_search_compiler_v2_response(client: AsyncClient) -> None: | |
| 200 | + body = (await client.get("/api/v1/search", params={"q": "Anthropic models released since 2025"})).json() | |
| 201 | + q = body["query"] | |
| 202 | + assert q["version"] == 2 and {"compiled", "residual", "unrecognised"} <= set(q) | |
| 203 | + assert q["organization"] == "Anthropic" and q["year_from"] == 2025 | |
| 204 | + assert any(c["filter"] == "organization" and isinstance(c["value"], dict) for c in q["compiled"]) | |
| 205 | + assert body["items"] and all(it["organization"]["slug"] == "anthropic" for it in body["items"]) | |
| 206 | + unknown = (await client.get("/api/v1/search", params={"q": "Foobarbaz models released since 2025"})).json()["query"] | |
| 207 | + assert "organization" not in unknown and "Foobarbaz" in unknown["unrecognised"] | |
| 208 | + | |
| 209 | + | |
| 210 | +async def test_etag_304(client: AsyncClient) -> None: | |
| 211 | + r1 = await client.get("/api/v1/stats") | |
| 212 | + etag = r1.headers.get("etag") | |
| 213 | + assert etag and etag.startswith('W/"') and "max-age" in r1.headers.get("cache-control", "") | |
| 214 | + r2 = await client.get("/api/v1/stats", headers={"if-none-match": etag}) | |
| 215 | + assert r2.status_code == 304 and r2.content == b"" and r2.headers.get("etag") == etag | |
| 216 | + admin = await client.get("/api/v1/admin/overview", headers=ADMIN) | |
| 217 | + assert "etag" not in admin.headers # admin responses are never cacheable | |
| 218 | + | |
| 219 | + | |
| 220 | +async def test_admin_rate_limit_before_auth(client: AsyncClient) -> None: | |
| 221 | + from aiatlas.api.common import _hits | |
| 222 | + | |
| 223 | + _hits.clear() | |
| 224 | + codes = [(await client.get("/api/v1/admin/overview", headers={"x-aia-admin-token": "wrong"})).status_code for _ in range(11)] | |
| 225 | + assert codes[:10] == [401] * 10 and codes[10] == 429, codes # 10 failed auths per minute per IP, then 429 before any token check | |
| 226 | + _hits.clear() | |
| 227 | + assert (await client.get("/api/v1/admin/overview", headers=ADMIN)).status_code == 200 | |
| 228 | + | |
| 229 | + | |
| 230 | +async def test_admin_workbenches_and_audit(client: AsyncClient) -> None: | |
| 231 | + from aiatlas.api.common import _hits | |
| 232 | + | |
| 233 | + _hits.clear() | |
| 234 | + q = (await client.get("/api/v1/admin/quality", headers=ADMIN)).json() | |
| 235 | + for key in ("duplicate_candidates", "taxonomy_violations", "impossible_values", "conflicting_t1_claims", "models_without_organization", "orphan_benchmark_results", | |
| 236 | + "unresolved_provider_deployments", "quantisations_typed_as_models", "stale_sources", "empty_public_categories", "quarantined_runs_pending", "review_queue_priority"): | |
| 237 | + assert key in q, key | |
| 238 | + assert {"count", "sample"} <= set(q["models_without_organization"]) | |
| 239 | + er = (await client.get("/api/v1/admin/entity-resolution", params={"limit": 3}, headers=ADMIN)).json() | |
| 240 | + assert {"items", "decisions"} <= set(er) and all({"a", "b", "signals", "hint"} <= set(i) for i in er["items"]) | |
| 241 | + for path in ("/api/v1/admin/anomalies", "/api/v1/admin/quarantine", "/api/v1/admin/audit?limit=5"): | |
| 242 | + assert (await client.get(path, headers=ADMIN)).status_code == 200, path | |
| 243 | + audit = (await client.get("/api/v1/admin/audit?limit=5", headers=ADMIN)).json() | |
| 244 | + assert audit["items"] and audit["items"][0]["actor"] == "admin" and audit["items"][0]["action"].startswith("GET /api/v1/admin/") | |
| 245 | + assert (await client.post("/api/v1/admin/quarantine/nope", headers=ADMIN, json={"action": "release"})).status_code in (404, 501) | |
| 246 | + assert (await client.post("/api/v1/admin/anomalies/nope", headers=ADMIN, json={"status": "resolved"})).status_code == 404 | |
| 247 | + | |
| 248 | + | |
| 249 | +async def test_claims_provenance_licenses_misc(client: AsyncClient) -> None: | |
| 250 | + cl = (await client.get(f"/api/v1/entities/{MODEL}/claims", params={"limit": 2})).json() | |
| 251 | + assert cl["items"] and cl["status"] == "current" | |
| 252 | + detail = (await client.get(f"/api/v1/claims/{cl['items'][0]['id']}")).json() | |
| 253 | + assert {"claim", "entity", "chain", "source", "extractor", "evidence"} <= set(detail) and {"snapshot_id", "document_url", "archived"} <= set(detail["evidence"]) | |
| 254 | + prov = (await client.get(f"/api/v1/entities/{MODEL}/provenance/context_length")).json() | |
| 255 | + assert {"value", "source", "tier", "observed_at", "claim_id", "conflicts", "history_count", "snapshot_id"} <= set(prov) | |
| 256 | + assert (await client.get(f"/api/v1/entities/{MODEL}/provenance/not_a_property")).status_code == 404 | |
| 257 | + lic = (await client.get("/api/v1/licenses")).json() | |
| 258 | + assert lic["items"] and all({"key", "commercial_use", "models"} <= set(i) for i in lic["items"]) | |
| 259 | + assert (await client.get("/api/v1/licenses/apache-2.0")).json()["key"] == "Apache-2.0" | |
| 260 | + meth = (await client.get("/api/v1/methodology")).json() | |
| 261 | + assert {"openness", "trust_levels", "comparability", "counters", "anomaly_checks", "event_semantics"} <= set(meth) | |
| 262 | + tr = (await client.get("/api/v1/trending", params={"kind": "most_changed"})).json() | |
| 263 | + assert tr["kind"] == "most_changed" and "definition" in tr | |
| 264 | + cmp_ = (await client.get("/api/v1/compare", params={"ids": f"{MODEL},{MODEL_B}", "diff_only": 1, "mode": "models"})).json() | |
| 265 | + assert cmp_["diff_only"] is True and "comparability" in cmp_ | |
| 266 | + for d in cmp_["dimensions"]: | |
| 267 | + vals = [repr(sorted(map(str, it["values"][d["key"]])) if isinstance(it["values"][d["key"]], list) else it["values"][d["key"]]) for it in cmp_["items"]] | |
| 268 | + assert len(set(vals)) > 1, d["key"] | |
| 269 | + diff = (await client.get(f"/api/v1/models/{MODEL}/diff/{MODEL_B}")).json() | |
| 270 | + assert all("delta" in d for d in diff["dimensions"]) | |
| 271 | + assert (await client.get("/api/v1/compare", params={"ids": f"{MODEL},{MODEL_B}", "mode": "providers"})).status_code == 400 | |
| 272 | + df = (await client.get("/api/v1/diff", params={"a": "2026-01-01", "b": "2026-12-31", "limit": 5})).json() | |
| 273 | + assert {"new_benchmark_leaders", "provider_changes", "hardware_changes", "context_changes", "retired_models"} <= set(df) and df["include_artifacts"] is False | |
| 274 | + tm = (await client.get("/api/v1/time-machine", params={"date": "2025-06-01", "scope": "models", "limit": 3})).json() | |
| 275 | + assert {"reconstructed", "first_entity_at", "note", "models"} <= set(tm) and all("attributes_as_of" in m for m in tm["models"]["items"]) | |
| 276 | + pulse = (await client.get("/api/v1/pulse", params={"days": 7})).json() | |
| 277 | + assert all({"value", "definition"} <= set(v) for v in pulse["counters"].values()) | |
| 278 | + idx = (await client.get("/api/v1/prices/index", params={"days": 14})).json() | |
| 279 | + assert {"series", "cheapest_frontier", "distribution", "new_listings_30d", "delistings_30d", "methodology"} <= set(idx) | |
| 280 | + assert all({"median_frontier_output", "median_open_output", "median_embedding_input", "sample"} <= set(s) for s in idx["series"]) | |
| 281 | + prov_list = (await client.get("/api/v1/providers")).json()["items"] | |
| 282 | + assert all({"input_price_distribution", "output_price_distribution", "models_added_30d", "models_removed_30d", "price_changes_30d", "organizations_covered", "features_supported"} <= set(p) for p in prov_list) | |
| 283 | + hw = (await client.get("/api/v1/hardware/apple-m3-ultra/fit", params={"limit": 3})).json() | |
| 284 | + assert hw["estimated"] is True and hw["memory_options_gb"] | |
| 285 | ||