GM Inventory Source (AEC-CP discovery API)
The inventory source fetches active new-vehicle inventory for each of the four GM
brands (Chevrolet, Buick, GMC, Cadillac) from Tekion's AEC-CP discovery API — the
same backend the brand marketing sites' "Search Inventory" pages use. Specific to
projects/ai_gm (src/ai_gm/sources/v1_inventory.py).
Upstream dependency
The inventory asset depends on catalog_model_slices_{brand} (the /vehicles/facets
response). The facets asset is materialized first; inventory reads the model list from
it to fan out one search query per model. This keeps the facets call to once per brand
per run rather than inline at every inventory execution.
catalog_model_slices_{brand} (/vehicles/facets)
↓
inventory_{brand} (/vehicles/search, one query per model)
Historical partitions (before today) bypass the live search entirely and read from BigQuery — the upstream model list is ignored for those partitions.
Endpoints
POST https://www.<brand>.com/<brand>/shopping/api/aec-cp-discovery-api/p/v1/vehicles/facets
POST https://www.<brand>.com/<brand>/shopping/api/aec-cp-discovery-api/p/v1/vehicles/search
| Brand | URL prefix | programId |
|---|---|---|
| Chevrolet | https://www.chevrolet.com/chevrolet/... | CHEVROLET |
| Buick | https://www.buick.com/buick/... | BUICK |
| GMC | https://www.gmc.com/gmc/... | GMC |
| Cadillac | https://www.cadillac.com/cadillac/... | CADILLAC |
aecloud.ioThe working endpoint is the brand-domain proxy (www.<brand>.com/...), which fronts
internal-<brand>.aecloud.io. Calling https://<brand>.aecloud.io/api/aec-cp-discovery-api/...
directly returns AEC_DISC_CONFIG_NOT_FOUND, because the registered client
configuration is bound to the proxy.
The client pivot config — the unlock
The discovery API is multi-tenant: it only answers requests that name a registered
pivot configuration via the client header. Without it, every request fails with
AEC_DISC_CONFIG_NOT_FOUND.
client: T1_VSR # Tier-1 "Vehicle Search Results" config — shared by all four GM brand sites
oemId: GM
programId: CHEVROLET | BUICK | GMC | CADILLAC
tenantId: 0
dealerId: 0
T1_VSR was captured from the live brand sites with Chrome DevTools (watching the
vehicles/search XHR on each brand's inventory page) and verified identical for
Chevrolet and Buick; only programId and the domain vary across brands. It is a public
value sent from the browser by the brand sites themselves — no partner registration or
credentials are required.
Request body
{
"filters": { "geo": { "zipCode": "11714", "radius": 10000 } },
"sort": { "name": "distance", "order": "ASC" },
"paymentTypes": ["CASH"],
"pagination": { "size": 20, "cursor": "<nextPageToken from previous response>" }
}
Coverage — one wide-radius query, no zip sweep
Search is geo-scoped, but the result count plateaus at the national total once the
radius covers the country. Measured from the fixed geographic anchor (11714, Bethpage, NY):
| radius (mi) | count |
|---|---|
| 100 | 113 |
| 500 | 9,971 |
| 1,500 | 50,199 |
| 10,000+ | 50,275 |
So one query per brand at US_NATIONWIDE_RADIUS (10,000 mi) from
US_GEOGRAPHIC_CENTER_ZIP returns every vehicle — no zip-list to maintain. The API
clamps oversized radii rather than erroring. Dealer details are resolved separately by
feeding the distinct inventory BACs into quantum-dealer-locator.
Pagination — cursor, not offset
- The response returns
data.pagination.nextPageToken; pass it back aspagination.nextPageTokenin the next request. The cursor walks the full result set (national counts far exceed 10k). pagination.sizeis capped server-side at 20 (requesting more still returns 20).- The offset form (
pagination.offset) is silently ignored by the server — every request returns the same first 20 vehicles regardless of offset value. Only the cursor produces correct sequential pages.
Parallelization — model-based slicing
The model list for each brand is supplied by the upstream catalog_model_slices_{brand}
asset (the /vehicles/facets response materialized separately). fetch_inventory fans
out one cursor-chain per model via parallel_events. Each worker thread runs its own
AkamaiSession with a shared throttle (~20 req/sec across all workers).
Models are a natural parallelization axis — they are non-overlapping by VIN, and
the model filter (filters.model.values) is native to the API. With 8 workers
and ~20 models per brand, the wall-clock time drops roughly 8×.
The facets response structure assumed:
{"data": {"model": [{"values": ["equinox ev"], "count": 97123}, ...]}}
Tuning env vars
fetch_inventory(brand, *, partition_key, zip_code=…, radius=…, max_pages=…) takes
overrides (used by tests/ad-hoc runs); unset args fall back to these env vars:
| Variable | Default | Description |
|---|---|---|
GM_INVENTORY_RADIUS | 10000 | Search radius in miles |
GM_INVENTORY_MAX_PAGES | (unset = full crawl) | Cap pages per model (20 vehicles each) |
GM_INVENTORY_WORKERS | 8 | Thread-pool size for model parallelism |
A bounded local validation run: GM_INVENTORY_MAX_PAGES=3 GM_INVENTORY_WORKERS=2.
Response structure & field mapping
The response is {"data": {"count": N, "hits": [...], "pagination": {...}}} with fields
flat on each hit (not the Elasticsearch hits.hits._source envelope). Mapping to
GmInventoryEntity (extract_inventory):
| Entity field | Source path | Notes |
|---|---|---|
vin | id | the VIN is the hit id |
brand | request param brand (else hit make) | |
model | model | |
model_year | year | string → int |
trim | variant.name | |
model_code | variant.code | e.g. 1TR58_1LS |
chrome_style_id | variant.chromeStyleId | |
body_style | bodyStyle | |
drive_type | driveType | |
fuel_type | fuelType | |
ext_color | baseExteriorColor | |
stock_number | stockDetails.stockNumber | |
condition | stockDetails.condition | e.g. NEW |
mileage | mileage | |
msrp | pricing.cash.msrp.value | string → Decimal |
net_price | pricing.cash.netPrice.value | string → Decimal |
availability_status | status.value | e.g. Available Now |
dealer_code | dealer.bac | dealer BAC — see below |
dealer_name | dealer.name | |
feature_refs | images[].url (i= param) | installed RPOs — see below |
ext_color_code | images[].url (first RPO) | paint code; the hit carries only a color name |
Fields the search response does not provide (left empty/null): engine,
transmission, interior/secondary colors, and invoice_price. These would require a
per-VIN vehicle-detail call.
Per-vehicle option codes (the image URL build sheet)
No GM endpoint returns per-vehicle option codes. The discovery response and the
vehicle-detail endpoint both carry feature names and descriptions only. The one
place the codes appear is the configurator image URL, whose i= query param ends
in the unit's installed RPOs, underscore-delimited and alphabetically sorted:
?i=2027/TPB26/TPB26__3SB/GXD_0ST_1NF_..._ZQ2_ZQ3gmds4.jpg&v=deg43&country=US
rpo_codes_from_image_url() parses these into feature_refs. Two details matter:
- Strip the renderer suffix before the extension. The suffix (
gmds4) is glued to the final code; reversing the order leavesZQ3gmds4as a bogus entry. - Accept 1–3 character codes.
Fis a real RPO — it sorts betweenEWEandF48in every vehicle that has it. A 3-character filter silently drops it.
The leading code is the exterior paint RPO, which also populates ext_color_code.
Measured over 80 live GMC listings: 100% carry a parseable build sheet (no silhouette fallbacks), 83–198 codes each, and 7 of 8 trims with more than one VIN had differing code sets — this is genuine per-vehicle build data, not per-trim boilerplate.
Resolution rate. Consolidated inventory resolves these into feature_ids via
[model_code, model_year, feature_refs.feature_code]. Most codes cannot resolve:
the image is a full factory build sheet while features only catalogs
consumer-facing equipment. Measured 2026-07-27 across 240 live listings (60 per
brand): 42,898 refs, 90.0% unresolved, median 18 features resolved per
vehicle. Codes such as EF7, MAH, UEU, V8D and 0ST appear on 238 of 240
vehicles across all four brands — manufacturing and regulatory entries, not
options. The FK threshold is set to 0.95 accordingly, matching the equivalent
Volkswagen join. feature_refs is dropped from the consolidated row after
resolution; it can be re-derived from raw at any time.
Dealer linkage (BAC)
Inventory identifies the selling dealer by BAC (Business Association Code), mapped to
dealer_code. This is GM's canonical dealer identifier. The quantum dealer source stores
the same 6-digit identifier as dealers.bac and keeps the separate 5-digit franchise code
as dealers.dealer_code. Consolidated inventory therefore joins inventory dealer_code
to dealers bac, scoped by brand.
Historical partitions (BigQuery)
All four brands have BigQuery tables in the ai-app-gm.data_sources_core dataset named
inventory-{brand}-en-us (e.g. inventory-chevrolet-en-us). Partitions before today are
read from BigQuery; today's partition fetches live.
Coverage start dates
| Brand | First BQ partition |
|---|---|
| Chevrolet | 2025-07-28 |
| GMC | 2025-07-27 |
| Cadillac | 2025-07-27 |
| Buick | 2026-02-11 |
Schema split
Chevrolet, GMC, and Cadillac share a schema where each BQ row has an entry column
containing the pre-serialized AEC discovery API JSON for that vehicle.
_wrap_records concatenates entry bytes directly into a synthetic page envelope
without parse/re-serialize.
Buick uses structured RECORD columns — the output of the product-data-pipeline
inventory mapper. _wrap_buick_structured reverse-maps them back to AEC format so
extract_inventory sees the same structure as live data.
Partition routing
GmInventoryHistoricalRawComponent.fetch_with_upstream() compares partition_key to
date.today(). Past dates route to _fetch_from_bq(); today's date uses the upstream
model list from catalog_model_slices_{brand} to run fetch_inventory live.
Environment variables
| Variable | Purpose | Example |
|---|---|---|
GCP_CREDENTIALS | Service account JSON for ai-app-gm | (base64 or file path) |
GM_GCP_PROJECT | Override GCP project (optional) | ai-app-gm |
For local development, run gcloud auth application-default login to set Application
Default Credentials without needing GCP_CREDENTIALS.