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

# Backfill missing Langfuse generation costs with Narev

> Backfill missing Langfuse costs from token usage with the Narev Pricing API, then update each existing generation without creating any duplicate traces.

You may need to backfill Langfuse costs when a generation records token usage without a dollar cost. This usually happens when the model is new, its name is an alias, or it doesn't match a Langfuse model definition.

You can repair those observations without recreating their traces. Read each generation from Langfuse, calculate its cost with Narev, then update the original observation by ID. The script at the end of this guide automates the process, but the steps below show what it does first.

## Before you start

You need Node.js 22 or later and a Langfuse public and secret key. The Narev pricing endpoints in this guide are public, so you don't need a Narev API key.

Choose a bounded time range for the backfill. Smaller ranges are easier to review and retry. They also prevent the script from scanning your complete Langfuse history.

```bash theme={null}
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_HOST="${LANGFUSE_HOST:-https://cloud.langfuse.com}"
export LANGFUSE_FROM="2026-07-01T00:00:00Z"
export LANGFUSE_TO="2026-08-01T00:00:00Z"
```

The start time is inclusive and the end time is exclusive. The example covers July 2026.

## 1. Find generations without costs

Start with the Langfuse Observations API v2. Ask for generation observations and include the `model` and `usage` field groups. You don't need prompts, outputs, or metadata to calculate cost.

```bash theme={null}
curl --fail-with-body \
  -u "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" \
  --get "$LANGFUSE_HOST/api/public/v2/observations" \
  --data-urlencode "fields=core,model,usage" \
  --data-urlencode "type=GENERATION" \
  --data-urlencode "fromStartTime=$LANGFUSE_FROM" \
  --data-urlencode "toStartTime=$LANGFUSE_TO" \
  --data-urlencode "limit=100"
```

Each generation eligible for backfill has an `id`, a `traceId`, a `providedModelName`, and `usageDetails`. Ignore observations that already have `costDetails.total` or `totalCost`.

Langfuse returns a cursor in `meta.cursor` when another page exists. Send that cursor with the next request and continue until it returns `null`. The complete script handles this loop, so a backfill isn't limited to the first page.

## 2. Translate the token usage

Narev needs prompt, completion, cache, and reasoning token counts. Langfuse stores the same information in named buckets inside `usageDetails`, but the names depend on the integration that created the observation.

For a typical Langfuse observation, map `input` to `prompt_tokens` and `output` to `completion_tokens`. Map `cache_read_input_tokens` and `cache_write_input_tokens` to their corresponding Narev cache fields. If the observation separates reasoning tokens into `output_reasoning_tokens`, send them as `reasoning_tokens`.

The important rule is to count each token once. Langfuse treats these values as separate buckets. If your integration includes cached tokens in `input`, subtract them before sending the prompt count to Narev.

<Warning>
  Inspect a few real `usageDetails` objects before running the backfill. Update the script's `mapUsage` function if your integration uses different field names.
</Warning>

## 3. Match the model to a price

Use the model name from `providedModelName` to search Narev:

```bash theme={null}
curl --fail-with-body --get "https://api.narev.ai/v1/prices/search" \
  --data-urlencode "q=claude-sonnet-4-6"
```

When the search returns one exact model ID from one provider, the script accepts that result and caches it for later observations.

Some names need an explicit decision. A gateway alias may not match a Narev model ID, and the same model may be sold by several providers. Add those names to `modelOverrides` in the complete script. This keeps an ambiguous match from producing the wrong cost.

## 4. Calculate and write the cost

Send the resolved model, provider, and token counts to the Narev cost endpoint:

```bash theme={null}
curl --fail-with-body -X POST "https://api.narev.ai/v1/traces/cost" \
  -H "Content-Type: application/json" \
  -d '{
    "model_id": "claude-sonnet-4-6",
    "provider_id": "anthropic",
    "usage": {
      "prompt_tokens": 1200,
      "completion_tokens": 340,
      "cache_read_tokens": 0,
      "cache_write_tokens": 0,
      "reasoning_tokens": 0
    }
  }'
```

Narev returns the dollar amount in `cost_breakdown.total`. Write that value to the existing generation with a Langfuse `generation-update` event. Reuse the observation's `id` and `traceId`; only the event envelope gets a new UUID.

Langfuse responds to ingestion requests with HTTP `207`, including successful requests. You still need to inspect the response's `errors` array. The script checks both the HTTP response and each event result before it reports an update as successful.

<Warning>
  Langfuse has deprecated this ingestion endpoint for new traces in favor of OpenTelemetry. This guide uses it because it can update an existing generation. Review the [Langfuse ingestion API](https://api.reference.langfuse.com/#tag/ingestion/POST/api/public/ingestion) before putting the backfill on a schedule.
</Warning>

## 5. Run the backfill

The script combines the four stages above. It pages through the selected time range, skips observations that already have costs, and handles one observation at a time. A bad model alias or unfamiliar usage shape doesn't stop the rest of the run.

Dry-run mode is enabled by default. In that mode, the script calculates and prints costs without changing Langfuse. Expand the code only when you're ready to save it as `backfill-langfuse-costs.mjs`.

<Accordion title="View the complete backfill script">
  ```javascript backfill-langfuse-costs.mjs theme={null}
  import { randomUUID } from "node:crypto";

  const required = [
    "LANGFUSE_PUBLIC_KEY",
    "LANGFUSE_SECRET_KEY",
    "LANGFUSE_FROM",
    "LANGFUSE_TO",
  ];

  for (const name of required) {
    if (!process.env[name]) {
      throw new Error(`Missing required environment variable: ${name}`);
    }
  }

  const host = process.env.LANGFUSE_HOST ?? "https://cloud.langfuse.com";
  const dryRun = process.env.DRY_RUN !== "false";
  const auth = Buffer.from(
    `${process.env.LANGFUSE_PUBLIC_KEY}:${process.env.LANGFUSE_SECRET_KEY}`,
  ).toString("base64");

  // Add aliases when a Langfuse model name does not exactly match one Narev row.
  const modelOverrides = {
    // "openrouter/anthropic/claude-sonnet-4-6": {
    //   modelId: "claude-sonnet-4-6",
    //   providerId: "anthropic",
    // },
  };

  const priceCache = new Map();

  async function requestJson(url, options = {}) {
    const response = await fetch(url, options);
    const text = await response.text();
    let body;

    try {
      body = text ? JSON.parse(text) : {};
    } catch {
      throw new Error(`${response.status} ${url}: expected JSON, received ${text}`);
    }

    if (!response.ok) {
      throw new Error(`${response.status} ${url}: ${JSON.stringify(body)}`);
    }

    return body;
  }

  async function* listUnpricedGenerations() {
    let cursor;

    do {
      const url = new URL("/api/public/v2/observations", host);
      url.searchParams.set("fields", "core,model,usage");
      url.searchParams.set("type", "GENERATION");
      url.searchParams.set("fromStartTime", process.env.LANGFUSE_FROM);
      url.searchParams.set("toStartTime", process.env.LANGFUSE_TO);
      url.searchParams.set("limit", "1000");
      if (cursor) url.searchParams.set("cursor", cursor);

      const page = await requestJson(url, {
        headers: { Authorization: `Basic ${auth}` },
      });

      for (const observation of page.data ?? []) {
        if (
          observation.costDetails?.total == null &&
          observation.totalCost == null
        ) {
          yield observation;
        }
      }

      cursor = page.meta?.cursor;
    } while (cursor);
  }

  function readCount(details, keys) {
    for (const key of keys) {
      if (Number.isInteger(details?.[key]) && details[key] >= 0) {
        return details[key];
      }
    }
    return 0;
  }

  function mapUsage(details) {
    const usage = {
      prompt_tokens: readCount(details, ["input", "prompt_tokens"]),
      completion_tokens: readCount(details, ["output", "completion_tokens"]),
      cache_read_tokens: readCount(details, [
        "cache_read_input_tokens",
        "input_cached_tokens",
        "cache_read_tokens",
      ]),
      cache_write_tokens: readCount(details, [
        "cache_write_input_tokens",
        "input_cache_creation",
        "cache_write_tokens",
      ]),
      reasoning_tokens: readCount(details, [
        "output_reasoning_tokens",
        "reasoning_tokens",
      ]),
    };

    if (
      usage.prompt_tokens === 0 &&
      usage.completion_tokens === 0 &&
      usage.cache_read_tokens === 0 &&
      usage.cache_write_tokens === 0 &&
      usage.reasoning_tokens === 0
    ) {
      throw new Error(`Unsupported usageDetails: ${JSON.stringify(details)}`);
    }

    return usage;
  }

  async function resolveModel(providedModelName) {
    if (modelOverrides[providedModelName]) {
      return modelOverrides[providedModelName];
    }

    if (priceCache.has(providedModelName)) {
      return priceCache.get(providedModelName);
    }

    const url = new URL("https://api.narev.ai/v1/prices/search");
    url.searchParams.set("q", providedModelName);
    url.searchParams.set("page_size", "100");
    const result = await requestJson(url);
    const exact = (result.data ?? []).filter(
      (row) => row.model_id === providedModelName && row.pricing,
    );

    if (exact.length !== 1) {
      throw new Error(
        `Expected one Narev price for ${providedModelName}, found ${exact.length}. Add a modelOverrides entry.`,
      );
    }

    const resolved = {
      modelId: exact[0].model_id,
      providerId: exact[0].provider_id,
    };
    priceCache.set(providedModelName, resolved);
    return resolved;
  }

  async function calculateCost(modelId, providerId, usage) {
    return requestJson("https://api.narev.ai/v1/traces/cost", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model_id: modelId,
        provider_id: providerId,
        usage,
      }),
    });
  }

  async function updateCost(observation, result) {
    const response = await requestJson(`${host}/api/public/ingestion`, {
      method: "POST",
      headers: {
        Authorization: `Basic ${auth}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        batch: [
          {
            id: randomUUID(),
            type: "generation-update",
            timestamp: new Date().toISOString(),
            body: {
              id: observation.id,
              traceId: observation.traceId,
              costDetails: { total: result.cost_breakdown.total },
              metadata: {
                narev_model_id: result.model_id,
                narev_provider_id: result.provider_id,
              },
            },
          },
        ],
        metadata: { batch_size: 1, sdk_name: "narev-guide" },
      }),
    });

    if ((response.errors ?? []).length > 0) {
      throw new Error(`Langfuse rejected the update: ${JSON.stringify(response.errors)}`);
    }
  }

  let priced = 0;
  let skipped = 0;

  for await (const observation of listUnpricedGenerations()) {
    try {
      if (!observation.providedModelName) {
        throw new Error("Observation has no providedModelName");
      }

      const usage = mapUsage(observation.usageDetails);
      const { modelId, providerId } = await resolveModel(
        observation.providedModelName,
      );
      const result = await calculateCost(modelId, providerId, usage);
      const total = result.cost_breakdown?.total;

      if (!Number.isFinite(total) || total < 0) {
        throw new Error(`Invalid Narev cost response: ${JSON.stringify(result)}`);
      }

      if (dryRun) {
        console.log(`[dry run] ${observation.id}: $${total}`);
      } else {
        await updateCost(observation, result);
        console.log(`Updated ${observation.id}: $${total}`);
      }
      priced += 1;
    } catch (error) {
      skipped += 1;
      console.error(`Skipped ${observation.id}: ${error.message}`);
    }
  }

  console.log(`${dryRun ? "Checked" : "Updated"} ${priced}; skipped ${skipped}`);
  if (skipped > 0) process.exitCode = 1;
  ```
</Accordion>

Run a dry run first:

```bash theme={null}
node backfill-langfuse-costs.mjs
```

Review the calculated costs and skipped observations. Then allow writes:

```bash theme={null}
DRY_RUN=false node backfill-langfuse-costs.mjs
```

<Check>
  A successful write prints `Updated <observation-id>: $<cost>`. Open that observation in Langfuse and confirm that its total cost matches the script output.
</Check>

## Troubleshooting

### A model couldn't be matched

`Expected one Narev price` means the search found no exact price or found the model under several providers. Add the Langfuse model name and your chosen Narev model and provider to `modelOverrides`.

### The usage shape isn't supported

`Unsupported usageDetails` means your Langfuse integration uses token field names that `mapUsage` doesn't recognize. Inspect the observation, add the relevant keys, and confirm that prompt, cache, and reasoning tokens don't overlap.

### Langfuse rejected an update

Read the event error returned with the HTTP `207` response. Confirm that the observation ID and trace ID came from the same generation and that the API keys belong to its Langfuse project.

### The script found nothing to update

Check the UTC date range first. If the range is correct, inspect one expected generation and confirm that it has `usageDetails` but doesn't already have `costDetails.total`.

### The calculated cost is too high

Check whether the `input` bucket includes cached tokens. If it does, subtract the cached count before sending `prompt_tokens`; otherwise, the script charges those tokens twice.

## Related documentation

* [`POST /v1/traces/cost`](/docs/platform/api-reference/v1/traces/cost)
* [`GET /v1/prices/search`](/docs/platform/api-reference/v1/prices/search)
* [Langfuse Observations API v2](https://langfuse.com/docs/api-and-data-platform/features/observations-api)
* [Langfuse ingestion API](https://api.reference.langfuse.com/#tag/ingestion/POST/api/public/ingestion)
