> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vantage.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom LLM Enrichment

> Attribute LLM spend to teams, users, applications, and features by delivering your own per-request token telemetry to an S3 bucket that Vantage reads and joins to your provider costs.

<Note>
  Contact Sales (or [support@vantage.sh](mailto:support@vantage.sh)/your account team) to enable LLM Enrichment for your account and discuss availability.
</Note>

Vantage reads your own per-request LLM usage logs from an S3 bucket and joins them to your model-provider costs. Each record describes one model call: the provider, model, token counts, and the allocation `tags` you attach. Vantage splits each matching cost row into enriched rows by token share and adds your `tags` to each row. This surfaces spend the provider bill never itemized, so you can filter and group it in [Cost Reports](/cost_reports), [Virtual Tags](/tagging), [Budgets](/budgets), and [Cost Alerts](/cost_alerts). Because you emit the telemetry yourself, this works with or without an AI gateway, even when applications share one API key the bill cannot break apart.

The **Custom LLM Enrichment** integration enriches costs for Vantage's native model-provider integrations: **OpenAI**, **Anthropic**, **AWS Bedrock**, **Google Cloud** (Vertex AI Gemini and Marketplace Claude), and **Azure**. You deliver one telemetry stream in the **Token Cost Allocation Specification**, a single, provider-agnostic schema, and Vantage routes each record to the matching provider's costs.

<Warning>
  **Using Amazon Bedrock?** Connect [AWS Bedrock LLM Enrichment](/aws_bedrock_enrichment) instead. It reads the Model Invocation Logs AWS already writes for you, so there is no telemetry to emit or maintain in your own applications. Reach for Custom LLM Enrichment for Bedrock only when your Bedrock traffic runs through an AI gateway that already emits per-request telemetry, or when you want one stream to cover several providers at once.

  Do not enable both for the same AWS account. Both features allocate the same Bedrock cost rows, so running them together counts the same usage twice in the token pool: your totals still reconcile, but each source's slices receive a diluted share and the attribution becomes misleading.
</Warning>

Custom LLM Enrichment is complementary to [Managed AI Tags](/managed_ai_tags): Managed AI Tags normalize the fields your providers already report into a shared `vntg:ai:` schema, while Custom LLM Enrichment enriches costs with per-request dimensions the provider bill leaves out. Enriched rows also carry `vntg:ai:` tags (including gateway-derived keys such as `vntg:ai:endpoint` and `vntg:ai:service_tier`) as described in [Enrichment Tag Reference](#enrichment-tag-reference).

<Note>
  Enrichment is metadata-only. Vantage reads the provider, model, token counts, and the allocation `tags` you choose to emit. Vantage does not collect or store your prompt or completion content, and it is never written to a Vantage-owned artifact. This data is not used to train any models.
</Note>

## How It Works

Your application (or the gateway in front of it) emits one JSON record per LLM request in the [Token Cost Allocation Specification](#step-1-emit-telemetry-in-the-common-schema). You write those records to an S3 bucket you own, and Vantage joins them to your provider costs during that provider's cost ingestion.

See [How LLM Enrichment works](/llm_enrichment#how-it-works) for the shared indexing, join, split, and tag pipeline.

### How Telemetry Matches Provider Costs

Provider bills typically aggregate many requests into token-usage line items, so Vantage does not match one telemetry record to one provider request. Instead, Vantage groups your records into daily usage buckets and joins those buckets to compatible provider cost rows.

Every provider uses the following base fields. The first four form the initial match; `resource_account_id` then scopes the result to the correct account.

| Telemetry field | Match method | Matched bill dimension | How Vantage uses it |
| - | - | - | - |
| `timestamp` | Exact match by UTC date | Billing date | Converts the request time to a UTC billing date. |
| `provider` | Exact match after normalization | Cost provider | Routes the record only to costs from the corresponding provider integration. |
| `model` | Provider-normalized match | Model or SKU identity | Normalizes the provider's model identifier and matches it to the model represented by the cost row. |
| `usage.*` | Exact match by token kind | Billed token category or SKU | Maps each positive token count to a billing usage kind: input, output, cache read, or cache write. Each kind matches separately. Input can be reported as an explicit uncached count or as a total input count that Vantage derives uncached input from; both resolve to the same input kind. |
| `resource_account_id` | Scope after the initial match | Provider account, project, or subscription | Restricts telemetry to costs from the same provider-native account. Include it when one bucket supplies telemetry for multiple integrations of the same provider. |

Some providers use additional fields to qualify the model match or to scope the result. Each row lists every provider the field applies to.

| Provider(s) | Telemetry field | Match method | Matched bill dimension | Matching behavior |
| - | - | - | - | - |
| OpenAI, Azure OpenAI | `service_tier` | Model qualifier with controlled fallback | Billing service tier (Azure: data-zone, regional, or global meter scope) | Keeps separately billed tiers distinct. OpenAI allows a compatible fallback when an exact tier is unavailable; Azure maps the tier to its meter scope. |
| OpenAI, Azure OpenAI | `model` snapshot or alias | Normalized model fallback | Billed model | Exact model matches are preferred; dated snapshots and their normalized short aliases can match when an exact counterpart is unavailable. |
| OpenAI | `endpoint` | Model qualifier with controlled fallback | Default or regional endpoint class | Regional telemetry matches regional-endpoint costs first. Telemetry without an endpoint retains compatibility with eligible costs. |
| OpenAI, Anthropic | `api_key_id` | Scope after the initial match | Provider API key or actor identity | Restricts telemetry to cost rows carrying the same provider-native identity. |
| Anthropic, Google Cloud Marketplace Claude | `tags.context_window` or `tags["anthropic:context_window"]` | Model qualifier with model-only fallback | Billed context window | Set the value to `0-200k` or `200k-1M` when known. If omitted, Vantage can share model-level telemetry across that model's window-specific cost rows. |
| Azure OpenAI, AWS Bedrock | `service_tier: "batch"` (the `is_batch` boolean is also accepted) | Model qualifier | Batch meter or token SKU | Keeps batch usage separate from non-batch usage. Prefer `service_tier`, which is also surfaced as a tag. |
| AWS Bedrock | `provider_region` | Model qualifier with wildcard fallback | Billed Region | Bedrock prices the same model differently per Region. |
| AWS Bedrock | `operation` | Model qualifier with wildcard fallback | Streaming or non-streaming inference | Bedrock prices streaming and non-streaming inference separately. |
| AWS Bedrock | `inference_scope` | Model qualifier with wildcard fallback | Global or regional inference profile | Bedrock prices global and regional inference profiles separately. If omitted, Vantage infers the scope from an inference-profile prefix on the model identifier. |
| AWS Bedrock | `identity_id`, `identity.id`, `identity_arn`, or `identity.arn` | Scope after the initial match | IAM principal | Restricts telemetry by principal when comparable identity data is present in CUR 2.0. |

For AWS Bedrock, `provider_region`, `operation`, and `inference_scope` are optional qualifiers rather than requirements. When your telemetry omits one, Vantage treats it as a wildcard that matches any value on the cost side, and it matches exactly qualified telemetry first so the wildcard only absorbs costs that nothing else claimed. Sending these fields produces more precise attribution when you run the same model across Regions, inference profiles, or streaming and non-streaming calls.

### How Cost Rows Are Split

Vantage splits each matching provider cost row into enriched rows, allocated proportionally by token share. Splits are additive, so the enriched rows always reconcile to the original total: usage your logs do not cover stays on a **leftover** row, and cost rows with no matching usage pass through unsplit. Enabling enrichment never changes your totals.

<Card title="How Cost Splitting Works" icon="calculator" href="/llm_enrichment#how-cost-rows-are-split">
  See the allocation formula, a worked example, and how leftover rows keep totals reconciled.
</Card>

### Data Freshness and Backfill

Enrichment runs as part of each provider's existing cost ingestion, so it follows that provider's refresh cadence. Recent days are reprocessed within a rolling three-day window so late-arriving logs are picked up. See the [provider data refresh documentation](/provider_data_refresh) for per-provider timing.

A billing period is enriched whenever it is processed while an active source exists, for as long as the matching logs remain in your bucket. Late-arriving logs for recent days are picked up automatically within the rolling three-day window. For older periods, Vantage re-enriches a day when the set of log objects for that day changes (files are added or removed) or when a full rebuild is triggered; replacing the contents of an existing object under the same key does not, on its own, re-trigger enrichment. Re-enrichment reads the already-normalized cost data, so it does not require a full cost re-import.

## Prerequisites

Before you begin, make sure:

* An active cost integration exists for at least one supported provider: [OpenAI](/connecting_openai), [Anthropic](/connecting_anthropic), [AWS](/connecting_aws), [Google Cloud](/connecting_gcp), or [Azure](/connecting_azure).
* An active [AWS integration](/connecting_aws) (cross-account IAM role) exists for the account that owns the telemetry bucket. Vantage reads the bucket through this role, so it is required even when the costs you enrich come only from OpenAI, Anthropic, Google Cloud, or Azure.
* You have an S3 bucket you own where you write (or will write) your telemetry.
* You have a Vantage **Organization Owner** or **Integration Owner** role. See [Role-Based Access Control](/rbac).

## Set Up Custom LLM Enrichment

Setup has two steps: [emit your telemetry in the common schema to S3](#step-1-emit-telemetry-in-the-common-schema) and [connect the source in Vantage](#step-2-connect-the-source-in-vantage), where you grant Vantage read access to the bucket and then review which providers Vantage detected. If you don't already produce these logs, the cold-start demo below turns an OpenAI or Anthropic API key into spec-conformant `YYYY/MM/DD/*.jsonl.gz` records you can deliver to S3, a fast way to see enrichment work before you instrument production traffic.

{(() => {
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
  if (typeof window === 'undefined') return;
  const check = () => setIsMobile(window.innerWidth <= 768);
  check();
  window.addEventListener('resize', check);
  return () => window.removeEventListener('resize', check);
}, []);
const innerStyle = isMobile
  ? { display: 'grid', gridTemplateColumns: '1fr', gap: 0, alignItems: 'stretch' }
  : { display: 'grid', gridTemplateColumns: 'minmax(0,1.2fr) minmax(0,1fr)', gap: 0, alignItems: 'stretch' };
const imageStyle = isMobile
  ? { minHeight: '180px', background: '#f8f7fa', backgroundImage: 'url(/img/custom-llm-source-cold-start.png)', backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' }
  : { minHeight: '190px', background: '#f8f7fa', backgroundImage: 'url(/img/custom-llm-source-cold-start.png)', backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' };
const contentStyle = isMobile
  ? { padding: '24px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }
  : { padding: '28px 32px', display: 'flex', flexDirection: 'column', justifyContent: 'center' };
return (
  <a
    href="https://github.com/vantage-sh/finops-as-code/tree/main/python/llm-telemetry-cold-start"
    target="_blank"
    rel="noopener noreferrer"
    style={{textDecoration:'none', color:'inherit', display:'block', maxWidth:'620px', borderRadius:'16px', overflow:'hidden', boxShadow:'0 4px 24px rgba(0,0,0,0.06)', border:'1px solid rgba(0,0,0,0.06)', transition:'box-shadow 0.2s ease, border-color 0.2s ease'}}
    onMouseEnter={(e) => {
      e.currentTarget.style.boxShadow = '0 8px 32px rgba(103, 66, 214, 0.15)';
      e.currentTarget.style.borderColor = 'rgba(103, 66, 214, 0.35)';
    }}
    onMouseLeave={(e) => {
      e.currentTarget.style.boxShadow = '0 4px 24px rgba(0,0,0,0.06)';
      e.currentTarget.style.borderColor = 'rgba(0,0,0,0.06)';
    }}
  >
    <div style={innerStyle}>
      <div style={imageStyle} aria-label="A Token Cost Allocation record emitted by the cold-start demo, shown in a code editor" />
      <div style={contentStyle}>
        <h3 style={{margin:0, marginBottom:'8px', fontSize:'1.2rem', fontWeight:700}}>Try the Cold-Start Demo</h3>
        <p style={{margin:0, marginBottom:'20px', lineHeight:1.6, opacity:0.85, fontSize:'0.95rem'}}>Turn an OpenAI or Anthropic API key into spec-conformant records in minutes.</p>
        <span style={{display:'inline-flex', alignItems:'center', gap:'8px', fontWeight:600, color:'#6742D6', fontSize:'0.95rem'}}>Open on GitHub <span style={{fontSize:'1.1em'}}>↗</span></span>
      </div>
    </div>
  </a>
);
})()}

### Step 1: Emit Telemetry in the Common Schema

Records follow the Token Cost Allocation Specification. Write one JSON object per request as newline-delimited JSON (JSONL), gzip-compressed, under a date-partitioned path:

```text theme={null}
s3://<bucket>/<optional-prefix>/YYYY/MM/DD/*.jsonl.gz
```

* Use the request timestamp's UTC date for the `YYYY/MM/DD` partition.
* Place the logs at the bucket root or under any prefix (subfolder); keep the `YYYY/MM/DD` date structure at the end of the path.
* Write one record per line; do not wrap records in an outer JSON array.
* Batch many records into larger gzipped files rather than writing one object per request.
* Vantage reads these logs from the connected bucket when importing provider cost data; logs are not pushed directly to Vantage.

A single record looks like this:

```json theme={null}
{
  "event_id": "ail_3EG...",                      // stable per-request dedup key
  "timestamp": "2026-05-26T17:59:06.235207Z",    // UTC, explicit Z
  "provider": "openai",                          // provider identity, separate from model
  "model": "gpt-5.2-2025-12-11",                  // model as reported by the provider
  "resource_account_id": "proj_abc123",          // provider-native account (e.g. OpenAI project)
  "provider_region": "us-east-1",                // provider region
  "service_tier": "default",                     // provider processing tier; use "batch" for batch requests
  "endpoint": "default",                         // default or regional
  "api_key_id": "key_abc123",                    // OpenAI key id or Anthropic actor email, never the secret sk-...
  "usage": {                                     // at least one positive token count required
    "input_tokens": 2129,
    "uncached_input_tokens": 1557,
    "output_tokens": 112,
    "cache_read_input_tokens": 572,
    "cache_write_input_tokens": 0
  },
  "tags": {                                      // arbitrary allocation dimensions
    "team": "growth",
    "purpose": "summarization"
  }
}
```

The sections below define each part of the record: [Core fields](#core-fields) are required on every record, [Usage fields](#usage-fields) carry the token counts (at least one must be a positive integer), [Optional fields](#optional-fields) add context and extra allocation dimensions, [Tagging guidance](#tagging-guidance) explains how `tags` and top-level metadata become cost dimensions, and [Record validity](#record-validity) covers what makes a record count.

#### Core Fields

These fields are required on every record.

| Field | Type | Requirement | Description |
| - | - | - | - |
| `event_id` | String | **Required** | Stable, unique identifier for the request. Vantage uses it to deduplicate records; it is not available as an allocation dimension. |
| `timestamp` | String | **Required** | Request time as an ISO 8601 timestamp in UTC with an explicit `Z` suffix. Determines the billing day the record joins to. |
| `provider` | String | **Required** | AI provider that handled the request. Kept separate from `model` so a provider's costs only match that provider's telemetry. *See the accepted values below.* |
| `model` | String | **Required** | Model identifier as reported by the provider, keeping any provider-specific prefixes (for example, Bedrock's `anthropic.` or Vertex's `models/`). This is the default join key against your provider cost data. |
| `usage` | Object | **Required** | Token counts for the request. At least one [usage field](#usage-fields) must be a positive integer. |

<Accordion title="Accepted provider values">
  Set `provider` to the identity that matches your cost integration.

  | Provider | Accepted `provider` values |
  | - | - |
  | OpenAI | `openai` |
  | Anthropic | `anthropic` |
  | AWS Bedrock | `aws`, `bedrock` (prefer [AWS Bedrock LLM Enrichment](/aws_bedrock_enrichment)) |
  | Google Cloud | `gcp`, `gemini`, `google`, `vertex` |
  | Azure | `azure` |
</Accordion>

<Note>
  Vantage normalizes model identifiers on both sides so gateway logs and provider billing join even when they differ. For example, OpenAI billing always uses dated model names (`gpt-5.2-2025-12-11`) while your logs may use dated or undated names (`gpt-5.2`); Vantage prefers the dated form and normalizes undated names to match.
</Note>

#### Usage Fields

The `usage` object is required. At least one field below must be a positive integer; no single field is always required, so include the counts that describe the request.

| Field | Description |
| - | - |
| `input_tokens` | Total input tokens, including cached tokens. Used to derive uncached input when neither explicit uncached field is present. |
| `non_cache_input_tokens` | Explicit uncached input token count. Preferred over `uncached_input_tokens` and `input_tokens` when present. |
| `uncached_input_tokens` | Explicit uncached input token count. Used when `non_cache_input_tokens` is absent. |
| `output_tokens` | Tokens generated by the model. |
| `cache_read_input_tokens` | Input tokens read from a cache. Missing values are treated as zero during uncached-input derivation. |
| `cache_write_input_tokens` | Input tokens written to a cache. Missing values are treated as zero during uncached-input derivation. |

Emit whichever input field your gateway reports. `non_cache_input_tokens`, `uncached_input_tokens`, and `input_tokens` all resolve to the same input token kind, so you do not need to send more than one. When neither explicit uncached field is present, Vantage derives uncached input as:

```text theme={null}
max(0, input_tokens - cache_read_input_tokens - cache_write_input_tokens)
```

#### Optional Fields

These fields are optional. Include the ones present on each request to improve matching and add allocation dimensions. `resource_account_id` is recommended when one bucket carries logs for multiple integrations of the same provider.

<Warning>
  If you connect more than one integration for the same provider (for example, two OpenAI accounts) and your telemetry does not include `resource_account_id`, Vantage cannot tell which integration each record belongs to, and those integrations' costs will not be enriched.
</Warning>

| Field | Type | Requirement | Description |
| - | - | - | - |
| `status` | String | Optional | Request status. Omit or leave it blank for successful requests, or set it to `success` (case-insensitive). Records with any other status are skipped. |
| `resource_account_id` | String | Recommended | Provider account that generated the usage (OpenAI project or organization, Anthropic workspace or organization, AWS usage account, Azure subscription, or Google Cloud project). Include it when one bucket supplies logs for multiple integrations from the same provider so Vantage matches each request to the correct costs. Must be top-level; `tags.resource_account_id` is ignored. |
| `service_tier` | String | Optional | Provider processing tier, and the preferred way to signal batch processing. Used for matching OpenAI and Azure OpenAI costs; for Azure OpenAI and AWS Bedrock, a case-insensitive value of `batch` separates batch from non-batch usage. Surfaced as the `vntg:ai:service_tier` tag. |
| `is_batch` | Boolean | Optional | Whether the request used batch processing. Still accepted for matching Azure OpenAI and AWS Bedrock costs, but `service_tier: "batch"` is preferred because `is_batch` is not surfaced as a tag on enriched rows. |
| `endpoint` | String | Optional | Endpoint class used for the request. Use `default` or `regional`. For OpenAI, enables matching regional-endpoint cost line items when present. A top-level value overrides `tags.endpoint`. |
| `api_key_id` | String | Optional | Provider-native API key identifier. For OpenAI, the `key_...` id from the usage or admin API (never the secret `sk-...` key); for Anthropic Analytics, the actor email associated with the request. Must be top-level. Supports grouping and allocation, and for OpenAI and Anthropic direct imports enables matching costs already split by API key. |
| `identity_id` | String | Optional | AWS (Bedrock) only. IAM principal that made the request (also accepts nested `identity.id`, or legacy `identity_arn` / `identity.arn`). Surfaced as the `vntg:ai:principal_id` tag regardless of your CUR format. When your AWS costs also use [CUR 2.0 with caller identity (IAM principal) allocation data enabled](/connecting_aws#cur-format), Vantage additionally matches by principal so a cost row only draws from invocations by that caller. |
| `provider_region` | String | Optional | Provider Region for the request. For AWS (Bedrock), used to match costs, because Bedrock prices the same model differently per Region. For other providers, available as a grouping and allocation dimension. |
| `operation` | String | Optional | AWS (Bedrock) only. API operation that served the request. Accepts `InvokeModel`, `Converse`, `InvokeModelWithResponseStream`, `ConverseStream`, or the normalized values `inference` and `streaming`. Separates streaming from non-streaming inference, which Bedrock prices separately. Unrecognized values are treated as though the field were omitted. |
| `inference_scope` | String | Optional | AWS (Bedrock) only. Whether the request used a global or regional inference profile. Use `global` or `regional`. If omitted, Vantage infers the scope from an inference-profile prefix on the model identifier (for example, `global.anthropic...` or `us.anthropic...`). |
| `request_id` | String | Optional | Gateway request identifier. Useful for troubleshooting, but not used to match telemetry to costs or added to allocation dimensions. |
| `provider_request_id` | String | Optional | Request identifier assigned by the provider. Useful for troubleshooting, but not used to match telemetry to costs or added to allocation dimensions. |
| `tags` | Object | Optional | Custom key/value dimensions for grouping, filtering, and allocating costs. Prefer stable keys with a limited set of values, such as `team`, `environment`, and `purpose`. Custom tags do not affect matching, except `context_window` or `anthropic:context_window` for Anthropic and Google Cloud Marketplace Claude. |

#### Tagging Guidance

These rules govern how your `tags` and top-level metadata become cost dimensions, and which values to keep out of `tags`.

* Top-level `service_tier`, `is_batch`, `provider_region`, `endpoint`, `api_key_id`, `operation`, and `inference_scope` values override fields with the same names inside `tags`.
* `resource_account_id` must be top-level and cannot be supplied through `tags`.
* To signal batch processing, set `service_tier` to `batch` rather than sending `is_batch`. Both are accepted for matching, but only `service_tier` is surfaced as a tag you can group and filter on; `is_batch` is used for matching and then dropped.
* For Anthropic and Google Cloud Marketplace Claude, set `tags.context_window` or `tags["anthropic:context_window"]` to `0-200k` or `200k-1M` when you know the request's context window. This value participates in matching; other custom tags do not.
* Keep `request_id`, `provider_request_id`, `flow_run_id`, and similar request identifiers out of `tags`, since they are high-cardinality and are not used as allocation dimensions.
* Avoid dimensions with very high cardinality or personally identifiable information (for example, email addresses or conversation identifiers) unless operationally necessary.

<Warning>
  Never include API key values, credentials, secrets, or other sensitive authentication material in any field or tag.
</Warning>

#### Record Validity

For a record to be counted, it must describe a successful request with usage Vantage can match to your costs. A record is skipped when:

* A required field (`event_id`, `timestamp`, `provider`, `model`, or `usage`) is missing, or the `timestamp` is blank or cannot be parsed.
* The `provider` is blank or not a supported value, or the `model` is blank or cannot be matched to your provider cost data.
* The `status` is set to anything other than `success` (omit it or leave it blank for successful requests).
* No `usage` field contains a positive integer.

### Step 2: Connect the Source in Vantage

In Vantage, go to the [Integrations page](https://console.vantage.sh/settings/integrations) and, under **LLM Enrichment**, add **Custom LLM Enrichment**, then select the S3 bucket that holds your telemetry. The connect screen walks you through granting read access and validating it, and connecting then scans your recent logs so you can review which providers were found.

<Note>
  You can connect one or more buckets per account. After you connect a bucket, Vantage scans your recent logs and you choose which of your supported provider cost integrations should receive enrichment from that bucket; you can change that selection later from the source's **Manage** tab. Provider cost integrations you connect *after* setting up a source are not enriched automatically; open the source's **Manage** tab and enable the new integration there.
</Note>

Connecting reuses your existing [AWS cross-account IAM role](/connecting_aws#how-vantage-connects-with-aws), adding read-only access to the bucket that holds your telemetry. It does not create a new role or require new credentials.

<Steps>
  <Step title="Select the bucket">
    Select the S3 bucket that receives your telemetry.

    <Note>
      If your bucket does not appear in the list, it may not be synced into Vantage's inventory yet. Newly created buckets can take up to 24 hours to appear.
    </Note>
  </Step>

  <Step title="Grant Vantage read access">
    <Warning>
      Deploy in the **AWS account that owns the telemetry bucket**, and make sure the bucket ARN in the policy matches the bucket you connect in Vantage. A mismatch is the most common cause of an `access denied` result when you check permissions.
    </Warning>

    The connect screen offers three ways to grant access, each prefilled for your account:

    <Tabs>
      <Tab title="AWS Console">
        The connect screen provides a CloudFormation quick-create link that opens the AWS Console with the stack name, template URL, and parameters prefilled. Acknowledge that CloudFormation may create IAM resources, then create the stack.

        The stack applies the [Vantage-published template](https://vantage-public.s3.amazonaws.com/vantage-integration-custom-llm-enrichment.json) with two parameters:

        * `VantageCrossAccountRole`: the name of your existing Vantage cross-account IAM role.
        * `CustomLlmEnrichmentBuckets`: a comma-separated list of telemetry bucket ARNs, each as `arn:aws:s3:::your-bucket/*`.

        Deploy the stack in the account that owns the bucket, not necessarily your management/payer account.
      </Tab>

      <Tab title="AWS CLI">
        The connect screen provides a ready-to-run `aws cloudformation create-stack` command using the same template and parameters as the Console method:

        ```bash theme={null}
        aws cloudformation create-stack \
          --stack-name CustomLLMEnrichmentSourceAccess-<id> \
          --template-url https://vantage-public.s3.amazonaws.com/vantage-integration-custom-llm-enrichment.json \
          --capabilities CAPABILITY_IAM \
          --region us-east-1 \
          --parameters \
            ParameterKey=VantageCrossAccountRole,ParameterValue=<your-cross-account-role-name> \
            ParameterKey=CustomLlmEnrichmentBuckets,ParameterValue=arn:aws:s3:::your-bucket/*
        ```

        Run the command with credentials for the AWS account that owns the bucket. For multiple buckets, pass a comma-separated list of ARNs as a single `ParameterValue`.
      </Tab>

      <Tab title="Terraform">
        If you manage your Vantage integration with the [`vantage-sh/vantage-integration/aws`](https://registry.terraform.io/modules/vantage-sh/vantage-integration/aws/latest) module, add the telemetry read permissions through the module's `additional_inline_policies` argument:

        ```hcl main.tf theme={null}
        module "vantage-integration" {
          source = "vantage-sh/vantage-integration/aws"

          additional_inline_policies = [
            {
              name = "CustomLLMEnrichmentSource-<access_credential_token>"
              policy = jsonencode({
                Version = "2012-10-17",
                Statement = [
                  {
                    Action   = ["s3:GetObject", "s3:GetObjectAcl"],
                    Resource = ["arn:aws:s3:::your-bucket/*"],
                    Effect   = "Allow"
                  }
                ]
              })
            }
          ]
        }
        ```

        This attaches the policy to your existing cross-account role; it does not create a new role. Bucket listing and location come from the base role, so only object read is added here. See the [Terraform documentation](/terraform#vantage-terraform-integrations-module-for-aws) for base module setup.
      </Tab>
    </Tabs>

    Whichever method you use, Vantage needs the following read-only access to validate and ingest telemetry:

    | Permission | When it is needed |
    | - | - |
    | `s3:GetObject` | Read the telemetry objects in the bucket you connect. |
    | `s3:ListBucket` | List objects under the log path to find your telemetry. |
    | `s3:GetBucketLocation` | Resolve the bucket's Region. |
    | `kms:Decrypt` | The bucket or objects are encrypted with AWS KMS (SSE-KMS). Without it, Vantage cannot read KMS-encrypted objects. |

    Your existing Vantage cross-account role already grants `s3:ListBucket` and `s3:GetBucketLocation`. The grant above adds only object read on the telemetry bucket (`s3:GetObject`, and the generated policy always includes `s3:GetObjectAcl`).

    <Note>
      The CloudFormation, CLI, and Terraform options above grant S3 read access only. If your telemetry bucket uses SSE-KMS encryption, you must grant `kms:Decrypt` separately. See [KMS-Encrypted Buckets](#kms-encrypted-buckets).
    </Note>
  </Step>

  <Step title="Check permissions and connect">
    Click **Check Permissions**. Vantage validates that it can resolve the bucket Region, list objects, and read a sample object. It also samples recent objects to verify the `YYYY/MM/DD/*.jsonl.gz` layout, gzip compression, and that records match the common schema. Blocking problems (for example, objects that are not under the date layout, uncompressed `.jsonl` logs, or sampled records missing required fields) are shown as errors you must resolve before connecting. Nonblocking findings still let you connect: an empty bucket is shown as a notice, while sibling integrations whose sampled logs omit `resource_account_id` produce a warning. Then click **Connect**. Vantage confirms the source and immediately scans your recent gateway logs, from the first day of the previous calendar month through today, to detect which providers and provider accounts appear in the bucket. You are taken to a **Scanning gateway logs** page that refreshes on its own; a large bucket can take several minutes.

    <Check>
      A success message confirms the source is connected and scanning has begun. When the scan finishes, a **Review** action appears for the source. See the [Troubleshooting](#troubleshooting) section for any errors that are displayed.
    </Check>
  </Step>

  <Step title="Review detected providers and confirm">
    When the scan finishes, select **Review** for the source. This opens the **Manage** tab of the source's details page. The review screen has one row per enrichable connected cost integration, grouped by provider, and shows each row's **Valid rows** and **Invalid rows** counts. Providers found in your logs that you have not connected a cost integration for appear as non-selectable rows. The **Notes** column shows **Success** when rows were found, **No rows found** when none were, and flags such as gateway logs missing `resource_account_id` or **Account identifiers not found: …** when account IDs could not be matched to a cost integration. Logs found for unsupported providers appear in a separate banner, not the Notes column. If more than 75% of the scanned rows failed validation, a warning ("N% of scanned log rows failed validation") suggests confirming that your objects use the common-schema `YYYY/MM/DD/*.jsonl.gz` format and include the required fields (`provider`, `model`, `usage`, and `status`).

    Choose which connected cost integrations should receive token enrichment from this bucket, then select **Confirm**. Vantage enriches costs for the selected integrations on each provider's next data refresh. You can change the selection later from the source's **Manage** tab.
  </Step>
</Steps>

## Manage Custom LLM Enrichment Sources

Manage your connected buckets from the **Custom LLM Enrichment** integration page. The sources table lists each bucket with its AWS account, creation date, and **Status** (for example, **Pending** before an import starts, **Importing** while one runs, **Stable** once all imports succeed, **Warning** if only some imports fail, **Error** if a scan or all imports fail, or **Paused** if the source is stopped). A shared-bucket warning icon appears next to the bucket name when sibling integrations require `resource_account_id`; error details appear beneath the status.

Select the **bucket name** to open that source's details page, which has two tabs:

* **Import History**, the default tab, shows every enrichment run for the source. See [View Import History](#view-import-history).
* **Manage** is where you choose which cost integrations receive enrichment. See [Choose Which Integrations Are Enriched](#choose-which-integrations-are-enriched).

A **Review** button appears in the row while scan results are waiting on your confirmation, and a **Resume** button appears for stopped sources; both open the **Manage** tab. The row's ellipses (**...**) menu contains **Stop** for sources that are not paused.

### Choose Which Integrations Are Enriched

After a scan finishes, select **Review** on the source to choose which connected cost integrations receive enrichment (see [Step 2](#step-2-connect-the-source-in-vantage)). To change that selection later, select the bucket name and open the **Manage** tab. The Manage tab lists each provider integration with its import status (for example, **Stable**, **Pending**, **Importing**, **Warning**, **Error**, **Paused**, or **Unenriched**), and for a provider with multiple accounts shows a count like *2 of 3 accounts enabled*. It lets you enable or disable enrichment per integration. Disabling an integration here stops going-forward enrichment for it but leaves its existing enriched history in place.

### View Import History

In the sources table, select the **bucket name** to open that source's details page, which shows the **Enrichment Runs** table for that bucket.

The **Enrichment Runs** table lists one row per provider cost integration and billing period that ran enrichment, newest first, with columns for the **Integration** (and its account), **Status**, **Tokens Kept**, **Log Lines Kept**, **Bill Match**, **Billing Period**, and **Last Enriched At**. Lifecycle changes appear as their own rows: **Added** when enrichment is first enabled for an integration, and **Paused** or **Resumed** when you stop, disable, resume, or re-enable it.

The three percentages look similar but measure different things at different stages, so a low number in one column means something very different from a low number in another:

| Metric | Stage | Counts | A Low Value Means |
| - | - | - | - |
| **Tokens Kept** | Join | Tokens | Your logs were read fine, but the usage in them did not line up with your provider bill. |
| **Log Lines Kept** | Log Scan | Log lines | Vantage could not read or index many of your log lines, usually because records are malformed or missing required fields. |
| **Bill Match** | Join | Provider cost rows | Many provider cost rows received no logged usage at all, so they passed through unsplit. Often your logs cover only part of your traffic. |

A low percentage does not change your totals. Usage Vantage cannot attribute stays on a **leftover** row, the unallocated remainder of the original cost row, so the enriched rows always add back up to what the provider billed.

Select the **Tokens Kept** percentage on any run to open **Token Metrics**, which breaks that run down by disposition across the two stages and lets you view sample records for anything that did not make it through.

<Card title="LLM Enrichment Import History" icon="table-list" href="/llm_enrichment_import_history">
  Full reference for the Import History screen: every column and status, how to read the three percentages, the Log Scan and Join dispositions with a fix for each, sample record fields, and a troubleshooting table.
</Card>

### Stop a Source

To stop a source, click the ellipses (**...**) next to the row and select **Stop**, then confirm in the **Stop enrichment source** dialog. This is a soft deactivation, not a hard delete: Vantage stops enriching new cost data for that bucket, but your existing enriched history is left unchanged, and the source stays visible in the list so you can bring it back at any time.

<Note>
  Stopping a source does not re-import or roll back already-enriched costs. Going-forward enrichment simply pauses for that bucket until you resume it.
</Note>

### Resume a Source

To resume a stopped source, select **Resume** in its row. Resuming restarts going-forward enrichment for the integrations that were active when you stopped the source (integrations you had already disabled individually stay disabled) and opens the **Manage** tab so you can adjust the selection. A stopped bucket cannot be re-added through the connect wizard; use **Resume** instead.

## View Enriched Costs on Cost Reports

Once enrichment runs, a single provider cost line is split into multiple rows, each carrying enrichment tags. You can filter and group by these tags anywhere tags are supported: [Cost Reports](/cost_reports), [Virtual Tags](/tagging), [Budgets](/budgets), and [Cost Alerts](/cost_alerts).

Because enrichment splits (allocates) your provider costs, enrichment tags behave like Vantage's [cost allocation tags](/tagging#cost-allocation-tags): you can build a Virtual Tag on them, but a cost can be allocated only once, so an enrichment tag can belong to only [one allocation chain](/tagging#virtual-tag-allocation-rules).

### Enrichment Tag Reference

| Tag key | Description | Example value |
| - | - | - |
| *Your `tags` keys* | Every key in a record's `tags` becomes a tag on the enriched rows for that usage slice. | `team`, `purpose`, `environment` |
| `vntg:ai:model` | The normalized model identifier that generated the cost, derived from the cost row. Vantage adds it to every enriched row. | `vntg:ai:model=gpt-5.2-2025-12-11` |
| `vntg:ai:model_provider` | The provider family inferred from the model (for example, OpenAI, Anthropic, Google, xAI), added automatically alongside `vntg:ai:model` when the family is recognized. | `vntg:ai:model_provider=OpenAI` |
| `vntg:ai:token_type` | The token kind the enriched row covers: `input`, `output`, `cache_read`, or `cache_write`. Added automatically to every enriched row. | `vntg:ai:token_type=input` |
| `vntg:ai:service_tier`, `vntg:ai:provider_region`, `vntg:ai:api_key_id`, `vntg:ai:endpoint`, `vntg:ai:operation`, `vntg:ai:inference_scope` | Promoted from the matching top-level telemetry fields when present and renamed under the prefix, so you can group and allocate by them too. | `vntg:ai:service_tier=default` |
| `vntg:ai:principal_id` | AWS (Bedrock) only. The normalized IAM principal that made the request, added when your telemetry includes `identity_id` (or `identity_arn`). | `my-role/session-name` |
| *Provider's existing tags* | Any tags Vantage already puts on the provider's cost rows are preserved on the enriched rows. If one of these shares a key with a slice `tag`, the enrichment value wins. | — |

Your own `tags` keys appear exactly as you emit them, with no provider prefix. The fields Vantage derives and adds itself, such as the model identifier, carry a `vntg:ai:` prefix (for example, `vntg:ai:model`); this keeps them distinct from your keys and consistent with the AI tags Vantage applies to provider costs. For example, on enriched OpenAI costs you would see `vntg:ai:model` with values like `gpt-5.2-2025-12-11`, and your own keys such as `team` with values like `growth`, not `openai:model` or `openai:team`. Because your key is just `team` (not provider-namespaced), the same key lines up across OpenAI, Anthropic, Azure, and the other providers, so you can group your entire AI stack by one `team` tag. Consistent key naming matters: `team` and `Team` are two different keys.

Enrichment tags behave like any other provider tag in the console:

* **To group:** open the **Group By** menu, select **Tag**, and choose the tag key, for example `vntg:ai:model`, or one of your own keys such as `team`.
* **To filter:** open the **Filters** menu, click **New Rule**, select **Tag**, choose the **Tag Key**, then pick an operator and one or more values.

The tag keys appear in the **Tag Key** dropdown once enriched costs exist. If you use a handful of keys often, mark them as [preferred tags](/tagging#preferred-tags) so they sort to the top of these menus.

<Note>
  Enriched rows retain the provider's existing tags and add the Vantage-managed `vntg:ai:*` tags (such as `vntg:ai:model`); split rows also carry the `tags` from their usage slice. When a slice `tag` and an existing tag use the same key, the enrichment value wins. The leftover row (usage not covered by telemetry) keeps the provider's existing tags and the applicable `vntg:ai:*` tags but carries none of the slice `tags`. Request identifiers (`event_id`, `request_id`, `provider_request_id`, `flow_run_id`) and `resource_account_id` are never turned into tags.
</Note>

## Troubleshooting

Most errors are displayed when you click **Check Permissions** or **Connect**. The tables below list the messages you may see, their cause, and how to resolve them. Expand a section for the relevant category.

<AccordionGroup>
  <Accordion title="Permission and access errors">
    | Message | Cause and fix |
    | - | - |
    | `Vantage cannot determine the location for bucket {bucket}. Grant s3:GetBucketLocation to the cross-account role.` | Add `s3:GetBucketLocation` to the cross-account role. |
    | `Vantage cannot list objects under s3://{bucket}/{prefix}. Grant s3:ListBucket for the configured prefix.` | Add `s3:ListBucket` scoped to the bucket (and prefix, if used). |
    | `Vantage cannot read object s3://{bucket}/{key}. Grant s3:GetObject to the cross-account role.` | Add `s3:GetObject` (and `s3:GetObjectAcl`) for the bucket. |
    | `Vantage cannot decrypt KMS-encrypted object s3://{bucket}/{key}. Grant kms:Decrypt to the cross-account role.` | The bucket uses SSE-KMS. Grant `kms:Decrypt` for the encrypting key. |
    | `Vantage could not assume the AWS cross-account role. Verify that the role exists, its trust policy allows Vantage to call sts:AssumeRole, and the configured external ID matches.` | Vantage cannot assume your [cross-account IAM role](/connecting_aws#how-vantage-connects-with-aws). Confirm the role still exists, its trust policy allows Vantage's `sts:AssumeRole`, and the external ID matches. |

    Confirm the permissions were deployed in the **AWS account that owns the bucket**, and that the bucket ARN in the policy matches the connected bucket.
  </Accordion>

  <Accordion title="Bucket and telemetry errors">
    | Message | Cause and fix |
    | - | - |
    | `Bucket {bucket} does not exist or could not be found.` | The bucket does not exist or is not reachable by the role. Verify the name and account. |
    | `No objects matching YYYY/MM/DD/*.jsonl.gz were found in s3://{bucket}/{prefix}. Vantage expects files in that date layout.` (or `No objects were found in s3://{bucket}/{prefix}.`) | The bucket has no `.jsonl.gz` objects under the expected `YYYY/MM/DD` path yet, or they use a different layout. Write a test record in the expected layout and wait for delivery, then re-check. |
    | `Vantage scanned 3000 objects in s3://{bucket}/{prefix} but could not find a matching log file. Use a dedicated bucket or a more specific prefix.` | Vantage scanned the first objects under the path without finding a matching log file. Point the source at a dedicated bucket or a more specific prefix so telemetry is found quickly. |
    | `Permission check timed out. The bucket may be large, or logs may not use the expected date layout.` | The permission check did not finish in time. Narrow the source to a dedicated bucket or prefix, confirm the `YYYY/MM/DD/*.jsonl.gz` layout, then re-check. |
    | `Bucket {names} could not be found for your account.` | The selected bucket is not in Vantage's synced inventory. Newly created buckets can take up to 24 hours to appear. |
    | `Connect an AWS, Anthropic, Azure, GCP, or OpenAI cost integration before checking Custom LLM enrichment permissions.` (the Connect action shows `...before connecting Custom LLM enrichment.`) | No enrichable cost integration exists. Connect a supported provider first. |
  </Accordion>

  <Accordion title="My source stays on Pending">
    **Pending** means Vantage has not run an enrichment import for this source yet. Enrichment runs during each provider's regular cost ingestion, so a newly connected source shows **Pending** until the next data refresh of a provider it enriches. See the [provider data refresh documentation](/provider_data_refresh) for per-provider timing. If the status is still **Pending** after that refresh, check the following:

    * If a **Review** button appears in the source's row, the scan finished and is waiting on you: select **Review**, choose the cost integrations to enrich, then select **Confirm** (see [Step 2](#step-2-connect-the-source-in-vantage)).
    * If you already confirmed, open the source's **Manage** tab and confirm that at least one integration is enabled.
    * Confirm your telemetry is still arriving in the bucket under the `YYYY/MM/DD/*.jsonl.gz` layout.
  </Accordion>

  <Accordion title="My costs are not being split">
    Enrichment can only split costs by dimensions present in your telemetry. Costs pass through without allocation splits when any of the following is true:

    * There is no active Custom LLM Enrichment source for the account, or the telemetry has not been ingested for that billing period.
    * The cost row's model or token type could not be matched to a logged request for that provider, date, and token kind.
    * Records were skipped during ingestion because a required field was missing or invalid, the `provider` was blank or unsupported, the `model` was blank or could not be matched to your provider cost data, the `status` was not successful, or no `usage` value was a positive integer.

    Confirm your records set `provider` to an [accepted value](#step-1-emit-telemetry-in-the-common-schema), carry a matching `model`, and include at least one positive usage count.
  </Accordion>

  <Accordion title="I can't tell which costs were enriched">
    Enrichment tags appear in the console as regular provider tags. To confirm what was enriched:

    * **Check coverage in Import History:** Open the source's [Import History](/llm_enrichment_import_history) and review **Tokens Kept** (the share of your logged tokens Vantage attached to a cost row) and **Bill Match** (the share of eligible provider cost rows that received logged usage) for each integration and billing period.
    * **Check a Cost Report:** Filter to the provider, then group by one of your own tag keys. Costs from requests that carried that key show its values. The leftover row (the portion of the bill your usage did not cover) and any usage sent without that key appear as **Not tagged with** the key name.
  </Accordion>

  <Accordion title="My totals changed after enabling enrichment">
    Totals should not change. Splits are additive and always sum to the original cost row to the cent, even when multiple sources are connected. If a total appears different, this is not expected behavior; contact [Vantage Support](mailto:support@vantage.sh).
  </Accordion>
</AccordionGroup>

### KMS-Encrypted Buckets

If the bucket you connect is encrypted with AWS Key Management Service (KMS), **Check Permissions** reports a decryption error until Vantage can read the objects. Either remove encryption on the bucket, or grant the cross-account role `kms:Decrypt` for the encrypting key and allow that role in the KMS key policy.

The CloudFormation, CLI, and Terraform options in [Step 2](#step-2-connect-the-source-in-vantage) grant S3 read access only, so KMS access must be added separately.

## Use Cases

Each row below shows example `tags` to emit on your telemetry and what that attribution enables in Vantage. The key names are examples; you choose your own, and Vantage surfaces whatever `tags` you send. All rows assume telemetry is delivered to a connected bucket.

| Use Case | Emit in `tags` | What It Enables |
| - | - | - |
| Chargeback by team | `team` | Group a [Cost Report](/cost_reports) by `team` for per-team LLM chargeback, or roll `team` values into a business hierarchy with a [Virtual Tag](/tagging). |
| Per-user attribution on shared keys | `user` | Attribute spend to the individual who made each request even when many callers share one API key, which the provider bill cannot break apart. |
| Per-feature or per-workflow spend | `feature`, `workflow` | Compare cost per product feature or workflow (for example, `summarization` vs. `classification`). |
| Model and effort mix | `mode`, `effort` | Spot macro patterns (which models and effort levels dominate spend) and outliers to guide defaults. The `vntg:ai:model` tag is added automatically, so you do not need to emit it. |
| Environment separation | `environment` | Filter non-production inference out of showback reports, or budget each environment (`production`, `staging`) separately. |

## Frequently Asked Questions

<AccordionGroup>
  <Accordion title="Which providers are supported?">
    OpenAI, Anthropic, AWS Bedrock, Google Cloud (Vertex AI Gemini and Marketplace Claude), and Azure. You deliver one telemetry stream in the common schema, and Vantage routes each record to the matching provider's costs based on its `provider` value. Azure support covers direct Azure integrations; Azure CSP billing accounts are not supported.

    For Amazon Bedrock, use the native [AWS Bedrock LLM Enrichment](/aws_bedrock_enrichment) integration instead, which reads the Model Invocation Logs AWS writes for you. Do not enable both for the same AWS account. They allocate the same Bedrock cost rows, so the same usage is counted twice in the token pool and each source's slices receive a diluted, misleading share.
  </Accordion>

  <Accordion title="What Vantage permissions do I need to enable this?">
    You need the **Organization Owner** or **Integration Owner** role to connect the source. Viewing the enriched costs does not require a special role; the resulting tags follow the same access rules as other cost data. See [Role-Based Access Control](/rbac).
  </Accordion>

  <Accordion title="What access does Vantage gain in my AWS account?">
    Read-only access to the bucket you connect: `s3:GetObject`, `s3:ListBucket`, `s3:GetBucketLocation`, and `kms:Decrypt` for KMS-encrypted objects (generated policies may also include `s3:GetObjectAcl`). The policy attaches to your existing cross-account role; no new role is created. See [Step 2: Connect the Source in Vantage](#step-2-connect-the-source-in-vantage).
  </Accordion>

  <Accordion title="Do I need an AI gateway to use this?">
    No. You emit the telemetry yourself, so this works for direct-to-provider traffic (for example, calling OpenAI or Anthropic APIs directly) as well as traffic behind a gateway. The only requirement is that you write common-schema records to S3.
  </Accordion>

  <Accordion title="Will this change my totals or break existing reports?">
    No. Splits are additive and always sum to the original cost row. Existing provider-level reports continue to show the same totals; enrichment only makes new dimensions available on the underlying rows.
  </Accordion>

  <Accordion title="Does Vantage see my prompt content?">
    No. Enrichment is metadata-only. The telemetry stream carries request identifiers, the provider and model, usage counts, and the allocation `tags` you choose to emit. Prompt and completion text are never collected or stored.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.