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 original project scaffold pointed at the bare
aecloud.io host and was stubbed out for this reason.
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. (The dealer locator is a different endpoint and still uses the zip sweep in
ai_core.geo.)
Pagination — cursor, not offset
- The response returns
data.pagination.nextPageToken; pass it back aspagination.cursorin 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 shape 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 shape & 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 |
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.
Dealer linkage (BAC)
Inventory identifies the selling dealer by BAC (Business Association Code), mapped to
dealer_code. This is GM's canonical dealer identifier. Note the dealer-locator source
keys dealers on its own vendorCode; if the two identifier spaces differ, the
consolidated foreign-key join from inventory → dealers must reconcile BAC against the
dealer entity's code. Verify this linkage when wiring inventory into the consolidated
tier.
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 shape 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.