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 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. 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 as pagination.nextPageToken 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 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:

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 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 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
feature_refsimages[].url (i= param)installed RPOs — see below
ext_color_codeimages[].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 leaves ZQ3gmds4 as a bogus entry.
  • Accept 1–3 character codes. F is a real RPO — it sorts between EWE and F48 in 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

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 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

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.