Skip to main content

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
BrandURL prefixprogramId
Chevrolethttps://www.chevrolet.com/chevrolet/...CHEVROLET
Buickhttps://www.buick.com/buick/...BUICK
GMChttps://www.gmc.com/gmc/...GMC
Cadillachttps://www.cadillac.com/cadillac/...CADILLAC
Use the brand domain, not aecloud.io

The 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
100113
5009,971
1,50050,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 as pagination.cursor in the next request. The cursor walks the full result set (national counts far exceed 10k).
  • pagination.size is 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:

VariableDefaultDescription
GM_INVENTORY_RADIUS10000Search radius in miles
GM_INVENTORY_MAX_PAGES(unset = full crawl)Cap pages per model (20 vehicles each)
GM_INVENTORY_WORKERS8Thread-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 fieldSource pathNotes
vinidthe VIN is the hit id
brandrequest param brand (else hit make)
modelmodel
model_yearyearstring → int
trimvariant.name
model_codevariant.codee.g. 1TR58_1LS
chrome_style_idvariant.chromeStyleId
body_stylebodyStyle
drive_typedriveType
fuel_typefuelType
ext_colorbaseExteriorColor
stock_numberstockDetails.stockNumber
conditionstockDetails.conditione.g. NEW
mileagemileage
msrppricing.cash.msrp.valuestring → Decimal
net_pricepricing.cash.netPrice.valuestring → Decimal
availability_statusstatus.valuee.g. Available Now
dealer_codedealer.bacdealer BAC — see below
dealer_namedealer.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

BrandFirst BQ partition
Chevrolet2025-07-28
GMC2025-07-27
Cadillac2025-07-27
Buick2026-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

VariablePurposeExample
GCP_CREDENTIALSService account JSON for ai-app-gm(base64 or file path)
GM_GCP_PROJECTOverride GCP project (optional)ai-app-gm

For local development, run gcloud auth application-default login to set Application Default Credentials without needing GCP_CREDENTIALS.